diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 42a669e1d..b44c97b80 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -22,6 +22,7 @@ import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/ import { LocaleContext, t as translate } from "@liveagent/ui/i18n/index"; import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { normalizeLogicalLineEndings } from "@liveagent/ui/lib/chat/composerText"; +import { deriveContextUsageTokens } from "@liveagent/ui/lib/chat/contextUsage"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; import { queuedChatTurnHasContent } from "@liveagent/ui/lib/chat/queuedChatTurn"; import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress"; @@ -39,6 +40,7 @@ import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; import { ChatComposerBar, type ChatQueueTurnPreview, + type ContextUsageTokensSource, } from "@liveagent/ui/pages/chat/ChatComposerBar"; import { SettingsPage } from "@liveagent/ui/pages/settings/SettingsPage"; import { @@ -253,6 +255,14 @@ import { UserMenu } from "./UserMenu"; const STALE_HISTORY_RETRY_INITIAL_DELAY_MS = 1_000; const STALE_HISTORY_RETRY_MAX_DELAY_MS = 30_000; +const MANUAL_COMPACTION_TIMEOUT_MS = 5 * 60_000; + +type ManualCompactPendingRequest = { + conversationId: string; + operationId: string; + workdir: string; + startedAt: number; +}; export default function GatewayApp() { const historyShareToken = useMemo(() => parseHistoryShareToken(), []); @@ -287,6 +297,35 @@ export default function GatewayApp() { ReadonlyMap >(new Map()); const [chatError, setChatError] = useState(null); + // 用量环手动压缩:以 operationId 关联桌面终态,避免 accepted 被误当作完成。 + // 按会话 id 键化(issue #359 缺陷 #3):不同会话各自独立 pending,一个会话压缩 + // 期间绝不静默屏蔽另一个会话的压缩请求。state 与 ref 经唯一 setter/clearer 同步 + // 写以保证两者一致。 + const [manualCompactPendingByConversation, setManualCompactPendingState] = useState< + ReadonlyMap + >(() => new Map()); + const manualCompactPendingRef = useRef>( + manualCompactPendingByConversation, + ); + const setManualCompactPendingRequest = useCallback((request: ManualCompactPendingRequest) => { + const next = new Map(manualCompactPendingRef.current); + next.set(request.conversationId, request); + manualCompactPendingRef.current = next; + setManualCompactPendingState(next); + }, []); + const clearManualCompactPendingRequest = useCallback( + (conversationId: string, operationId: string) => { + const current = manualCompactPendingRef.current; + const pending = current.get(conversationId); + if (!pending || pending.operationId !== operationId) return false; + const next = new Map(current); + next.delete(conversationId); + manualCompactPendingRef.current = next; + setManualCompactPendingState(next); + return true; + }, + [], + ); // Top-right toast stack for upload/attachment feedback — mirrors the GUI's // NotifyToast usage so upload failures never render as conversation output. const [notifyItems, setNotifyItems] = useState([]); @@ -1726,6 +1765,36 @@ export default function GatewayApp() { }; }, [activityStore, api, chatCommandPipeline]); + const settleManualCompactionResult = useCallback( + ( + targetConversationId: string, + result: { + operationId: string; + status: "compacted" | "failed" | "busy" | "skipped"; + message?: string; + }, + ) => { + const pending = manualCompactPendingRef.current.get(targetConversationId); + if (!pending || pending.operationId !== result.operationId) { + return; + } + if (!clearManualCompactPendingRequest(targetConversationId, result.operationId)) return; + if (result.status === "compacted") { + return; + } + const fallbackKey = + result.status === "skipped" + ? "chat.manualCompactBelowThreshold" + : result.status === "failed" + ? "chat.manualCompactFailed" + : "chat.manualCompactRejected"; + // 缺陷 #4:无论目标会话是否正被显示都提示——用户切走后压缩失败/跳过也要 + // 看到结果。message 本身已说明这是压缩结果,故不再以 isDisplayedConversation 门控。 + setChatError(result.message || translate(fallbackKey, settings.locale)); + }, + [clearManualCompactPendingRequest, settings.locale], + ); + // App-level observation of the displayed conversation's stream: titles, // pipeline settlement, queue refreshes, tunnel side effects, and the one // scroll-compensated fold commit at run_started. @@ -1741,6 +1810,10 @@ export default function GatewayApp() { ? ((event as { client_request_id: string }).client_request_id ?? "").trim() : ""; switch (event.type) { + case "manual_compaction_result": { + settleManualCompactionResult(targetConversationId, event); + return; + } case "run_started": { // The fold this event triggers in the store is a pure data // transition of the single row list (identical row keys, same DOM @@ -1800,6 +1873,7 @@ export default function GatewayApp() { chatCommandPipeline, handleTunnelManagerChatEvent, refreshChatQueueSnapshot, + settleManualCompactionResult, ], ); @@ -1844,6 +1918,15 @@ export default function GatewayApp() { // regardless of running state, which is what makes GUI queue auto-sends // race-free: the next run's events simply flow in). const displayedConversationId = resolveVisibleConversationId(selectedHistoryId, conversationId); + // composer 的用量环禁点:仅当显示会话自身有 pending(缺陷 #3:不再被其他会话的 + // pending 静默屏蔽)。 + const manualCompactPending = manualCompactPendingByConversation.has(displayedConversationId); + // sidebar 瞬态“转圈”:所有 pending 会话(可多个)——ManualCompactPendingRequest + // 结构上兼容 TransientSidebarRunningConversation(conversationId + workdir)。 + const manualCompactTransientConversations = useMemo( + () => Array.from(manualCompactPendingByConversation.values()), + [manualCompactPendingByConversation], + ); // 会话生效模型:本地 override > sidebar 行携带的持久化选择 > 全局默认。 const selectionForConversation = useCallback( @@ -1898,6 +1981,79 @@ export default function GatewayApp() { }); displayedConversationBusyRef.current = displayedConversationBusy; + // 后台订阅接线经 ref 稳定化(缺陷 #5):handleConversationStreamEvent/Sync 的 + // useCallback 身份随 locale/pipeline 回调变化,若入 effect 依赖会在 pending 期间 + // 反复退订/重订并重放全量 sync。改用 ref 读取最新实现,effect 只依赖 + // (api, pending 键集, displayedConversationId)。 + const handleConversationStreamEventRef = useRef(handleConversationStreamEvent); + handleConversationStreamEventRef.current = handleConversationStreamEvent; + const handleConversationStreamSyncRef = useRef(handleConversationStreamSync); + handleConversationStreamSyncRef.current = handleConversationStreamSync; + + // store settle:对每个 pending 会话订阅其 store,终态到达即结算(缺陷 #3:按 + // 会话独立,一个 effect 内循环收集 cleanup)。 + useEffect(() => { + if (manualCompactPendingByConversation.size === 0) return; + const cleanups: Array<() => void> = []; + for (const pending of manualCompactPendingByConversation.values()) { + const store = transcriptStoreRegistry.get(pending.conversationId); + const settleFromStore = () => { + const result = store.getSnapshot().manualCompactionResult; + if (result?.operationId === pending.operationId) { + settleManualCompactionResult(pending.conversationId, result); + } + }; + settleFromStore(); + cleanups.push(store.subscribe(settleFromStore)); + } + return () => { + for (const cleanup of cleanups) cleanup(); + }; + }, [manualCompactPendingByConversation, settleManualCompactionResult, transcriptStoreRegistry]); + + // 5 分钟超时:每个 pending 各自计时(缺陷 #4:超时也无论是否显示都提示)。 + useEffect(() => { + if (manualCompactPendingByConversation.size === 0) return; + const timeoutIds: number[] = []; + for (const pending of manualCompactPendingByConversation.values()) { + const remaining = Math.max(0, pending.startedAt + MANUAL_COMPACTION_TIMEOUT_MS - Date.now()); + const timeoutId = window.setTimeout(() => { + if (clearManualCompactPendingRequest(pending.conversationId, pending.operationId)) { + setChatError(translate("chat.manualCompactTimedOut", settings.locale)); + } + }, remaining); + timeoutIds.push(timeoutId); + } + return () => { + for (const id of timeoutIds) window.clearTimeout(id); + }; + }, [clearManualCompactPendingRequest, manualCompactPendingByConversation, settings.locale]); + + // 后台会话订阅:显示会话已由 useConversationChat 订阅,这里补齐其余 pending 会话 + // 的流,使其 store 收到 manual_compaction_result 终态(缺陷 #3 多会话、缺陷 #5 稳定)。 + useEffect(() => { + if (!api || manualCompactPendingByConversation.size === 0) return; + const cleanups: Array<() => void> = []; + for (const pending of manualCompactPendingByConversation.values()) { + if (pending.conversationId === displayedConversationId) continue; + const store = transcriptStoreRegistry.get(pending.conversationId); + const cleanup = api.subscribeConversationStream(pending.conversationId, { + onSync: (result) => { + store.applySync(result); + handleConversationStreamSyncRef.current(pending.conversationId, result); + }, + onEvent: (event) => { + store.applyEvent(event); + handleConversationStreamEventRef.current(pending.conversationId, event); + }, + }); + cleanups.push(cleanup); + } + return () => { + for (const cleanup of cleanups) cleanup(); + }; + }, [api, displayedConversationId, manualCompactPendingByConversation, transcriptStoreRegistry]); + // Open in flight (history-window fetch, before the replace apply paints). const historyDetailLoading = conversationOpenState.phase === "opening"; @@ -4434,6 +4590,35 @@ export default function GatewayApp() { () => selectLatestTaskProgress(transcriptRows), [transcriptRows], ); + // 用量环读数(缺陷 #6):最近 assistant 轮次的真实 usage;压缩后回退检查点估算。 + // 不再以逐帧变化的标量 prop 传入 memo 化的 ChatComposerBar(那会让整枚 composer + // 流式期间每帧重渲染、打字卡顿),而是构造订阅隔离的 source:订阅显示会话的 + // transcript store,getContextUsageTokens 惰性 derive 并按 store revision 缓存, + // revision 未变直接复用——只有环自身经 useSyncExternalStore 重渲染。 + // 本 memo 是 contextUsageTokens 的唯一消费者迁移点:迁移后 GatewayApp 不再持有 + // 标量读数(无 mobile header 等其他消费者)。 + const contextUsageTokensSource = useMemo(() => { + const store = displayedConversationId + ? transcriptStoreRegistry.get(displayedConversationId) + : null; + if (!store) { + return { subscribe: () => () => {}, getContextUsageTokens: () => undefined }; + } + let cache: { revision: number; value: number | undefined } | null = null; + return { + // store 的 subscribe/getSnapshot 是闭包属性,可安全解绑传递。 + subscribe: store.subscribe, + getContextUsageTokens: () => { + const snapshot = store.getSnapshot(); + if (cache && cache.revision === snapshot.revision) { + return cache.value; + } + const value = deriveContextUsageTokens(snapshot.rows); + cache = { revision: snapshot.revision, value }; + return value; + }, + }; + }, [displayedConversationId, transcriptStoreRegistry]); // 当前会话的待审批工具:遍历渲染中的 transcript,筛出带 __toolApprovalPending 标记 // 且尚无结果的 tool call(与 ToolCallItem 判定同源)。用于输入框上方的集中审批栏, // 取代埋在各折叠项里的分散卡片。快照 revision 变化时经 useConversationChat 重渲染, @@ -4514,6 +4699,50 @@ export default function GatewayApp() { const composerIsSending = transcriptBusy; const transcriptError = displayedTranscriptRowCount === 0 ? null : chatError; const composerCompactionBlocked = transcriptToolStatusIsCompaction; + // 手动压缩:受理回包只表示桌面开始处理;按钮保持 pending,直到同一 + // operationId 的 manual_compaction_result 终态到达。探针拒绝(低于阈值/无内容/忙) + // 时桌面同步回 accepted:false + message,走 !accepted 分支即时清 pending。 + const handleManualCompact = useCallback(async () => { + const conversationIdValue = getDisplayedConversationId(); + // 缺陷 #3:只在“同会话”已有 pending 时拒绝(静默 return 即可,UI 已 blocked); + // 其他会话的 pending 不再屏蔽本会话的压缩请求。 + if (!api || !conversationIdValue || manualCompactPendingRef.current.has(conversationIdValue)) { + return; + } + const operationId = createUuid(); + const pendingRequest: ManualCompactPendingRequest = { + conversationId: conversationIdValue, + operationId, + workdir: displayedConversationWorkdir, + startedAt: Date.now(), + }; + setManualCompactPendingRequest(pendingRequest); + try { + const response = await api.chatQueueCompactNow(conversationIdValue, operationId); + if ( + !response.accepted && + clearManualCompactPendingRequest(conversationIdValue, operationId) && + isDisplayedConversation(conversationIdValue) + ) { + setChatError(response.message || translate("chat.manualCompactRejected", settings.locale)); + } + } catch (error) { + if ( + clearManualCompactPendingRequest(conversationIdValue, operationId) && + isDisplayedConversation(conversationIdValue) + ) { + setChatError( + asErrorMessage(error, translate("chat.manualCompactRejected", settings.locale)), + ); + } + } + }, [ + api, + clearManualCompactPendingRequest, + displayedConversationWorkdir, + setManualCompactPendingRequest, + settings.locale, + ]); const chatProtocolIncompatible = isChatRuntimeProtocolIncompatible(status); const chatProtocolIncompatibleMessage = chatProtocolIncompatible ? translate("chat.runtime.protocolIncompatible", settings.locale) @@ -4676,6 +4905,7 @@ export default function GatewayApp() {
( export type GatewaySidebarContainerProps = { store: SidebarStore; + // 手动压缩 pending 已按会话 id 键化,多个会话可同时“转圈”(issue #359 缺陷 #3)。 + transientRunningConversations?: readonly TransientSidebarRunningConversation[]; currentConversationId: string; isOpen: boolean; fontScale?: number; @@ -155,6 +159,19 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { sidebarShallowEqual, ); const conversationIndex = useSidebarSelector(store, selectConversationIndex); + const effectiveRunningActivity = useMemo( + () => + mergeTransientSidebarRunningActivity( + runningConversationIds, + projectActivityInputs.runningWorkdirPathKeys, + props.transientRunningConversations, + ), + [ + projectActivityInputs.runningWorkdirPathKeys, + props.transientRunningConversations, + runningConversationIds, + ], + ); // --- Rename UI state (moved out of GatewayApp) --------------------------- const [renamingId, setRenamingId] = useState(null); @@ -336,9 +353,13 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { () => sortWorkspaceProjectsByActivity(projects, { projectActivityUpdatedAts: projectActivityInputs.workdirActivity, - runningProjectPathKeys: projectActivityInputs.runningWorkdirPathKeys, + runningProjectPathKeys: effectiveRunningActivity.runningProjectPathKeys, }), - [projectActivityInputs.runningWorkdirPathKeys, projectActivityInputs.workdirActivity, projects], + [ + effectiveRunningActivity.runningProjectPathKeys, + projectActivityInputs.workdirActivity, + projects, + ], ); return ( @@ -346,7 +367,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { items={items} currentConversationId={props.currentConversationId} busyConversationIds={mutations} - runningConversationIds={runningConversationIds} + runningConversationIds={effectiveRunningActivity.runningConversationIds} listStatus={listState.status} scopeKey={scopeKey} totalItems={listState.totalCount} @@ -364,7 +385,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { projects={sortedProjects} activeProjectId={props.activeProjectId} missingProjectPathKeys={props.missingProjectPathKeys} - runningProjectPathKeys={projectActivityInputs.runningWorkdirPathKeys} + runningProjectPathKeys={effectiveRunningActivity.runningProjectPathKeys} projectRenamingId={props.projectRenamingId} projectRenameDraft={props.projectRenameDraft} projectsCollapsed={props.projectsCollapsed} diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 3232e8795..10daff39e 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -200,10 +200,16 @@ function CheckpointCard(props: { readOnly?: boolean; }) { const { item, readOnly = false } = props; + const { t } = useLocale(); const [expanded, setExpanded] = useState(false); const isExpanded = expanded; const messageCountLabel = - item.coveredMessageCount > 0 ? `${item.coveredMessageCount} 条消息` : "已压缩"; + item.coveredMessageCount > 0 + ? t("chat.contextCheckpoint.messageCount").replace( + "{count}", + String(item.coveredMessageCount), + ) + : t("chat.contextCheckpoint.compressed"); const headerContent = ( <>
@@ -213,7 +219,7 @@ function CheckpointCard(props: {
- 上下文检查点 + {t("chat.contextCheckpoint.title")} {messageCountLabel} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index c25a807ff..d067e936b 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -194,6 +194,18 @@ export const translations: Record> = { "chat.changedFiles.expand": "展开文件列表", "chat.compactingContext": "正在压缩上下文", "chat.compactingContextWait": "正在压缩上下文,请稍候...", + "chat.contextCheckpoint.title": "上下文检查点", + "chat.contextCheckpoint.messageCount": "{count} 条消息", + "chat.contextCheckpoint.compressed": "已压缩", + "chat.manualCompactTitle": "手动压缩上下文?", + "chat.manualCompactDescription": "将历史消息折叠为摘要检查点,释放上下文空间。", + "chat.manualCompactConfirm": "压缩", + "chat.manualCompactRejected": "桌面端暂无法压缩(会话运行中或正在压缩)", + "chat.manualCompactBelowThreshold": "上下文占用尚未达到 50%,暂不需要压缩", + "chat.manualCompactEmpty": "当前会话没有可压缩的上下文", + "chat.manualCompactUnavailable": "当前模型缺少上下文配置,无法压缩", + "chat.manualCompactFailed": "上下文压缩失败,请稍后重试", + "chat.manualCompactTimedOut": "上下文压缩未返回结果,请确认桌面端在线后重试", "chat.editMessage": "编辑消息", "chat.cancel": "取消", "chat.queue.title": "等待队列 {count}", @@ -2423,6 +2435,20 @@ export const translations: Record> = { "chat.changedFiles.expand": "Expand file list", "chat.compactingContext": "Compressing context", "chat.compactingContextWait": "Compressing context, please wait...", + "chat.contextCheckpoint.title": "Context Checkpoint", + "chat.contextCheckpoint.messageCount": "{count} msgs", + "chat.contextCheckpoint.compressed": "Compressed", + "chat.manualCompactTitle": "Compact context manually?", + "chat.manualCompactDescription": + "Folds earlier messages into a summary checkpoint to free context space.", + "chat.manualCompactConfirm": "Compact", + "chat.manualCompactRejected": "Desktop cannot compact right now (busy or already compacting)", + "chat.manualCompactBelowThreshold": "Context usage is below 50%; compaction is not needed yet", + "chat.manualCompactEmpty": "This conversation has no context to compact", + "chat.manualCompactUnavailable": "Context compaction is unavailable for the current model", + "chat.manualCompactFailed": "Context compaction failed. Please try again", + "chat.manualCompactTimedOut": + "Context compaction did not return a result. Check the desktop connection and try again", "chat.editMessage": "Edit Message", "chat.cancel": "Cancel", "chat.queue.title": "Queue {count}", diff --git a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts index 26bebeae8..0068571fd 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts @@ -72,6 +72,7 @@ const EMPTY_TRANSCRIPT: TranscriptSnapshot = { toolStatus: null, toolStatusIsCompaction: false, retryAttempts: [], + manualCompactionResult: null, needsHistoryRefresh: false, foldRevision: 0, revision: 0, diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts b/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts index fae9eddaa..5d0c6d209 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts @@ -219,6 +219,7 @@ export function buildRowsFromEntries( summaryId: entry.summaryId, coveredMessageCount: entry.coveredMessageCount, generatedBy: entry.generatedBy, + contextUsageTokens: entry.contextUsageTokens, timestamp: entry.timestamp, }); } else { @@ -391,24 +392,36 @@ export function buildTurnRows(turn: Turn): TranscriptRow[] { // construction, but this single canonical pass makes the guarantee local to // the builder instead of distributed across every producer. Pass `seen` to // dedupe one region against keys already taken by another. +// +// Checkpoint rows are the exception to rename-on-collision: their id is a +// content identity (`checkpoint-`), so a colliding checkpoint is +// the SAME logical card seen from two sources — the history region and the +// (user-less) manual-compaction turn that streamed it. Renaming would render +// the card twice; the later copy is dropped instead (region order puts the +// history copy first, and the shared key keeps React/measurement identity +// stable when the rendering source flips). export function dedupeRowKeys(rows: TranscriptRow[], seen = new Set()): TranscriptRow[] { let next: TranscriptRow[] | null = null; for (let index = 0; index < rows.length; index += 1) { const row = rows[index]; if (!row) continue; - let key = row.key; - if (seen.has(key)) { + if (seen.has(row.key)) { + if (row.kind === "checkpoint") { + next ??= rows.slice(0, index); + continue; + } let suffix = 2; - while (seen.has(`${key}#${suffix}`)) { + while (seen.has(`${row.key}#${suffix}`)) { suffix += 1; } - key = `${key}#${suffix}`; - if (!next) { - next = rows.slice(); - } - next[index] = { ...row, key }; + const key = `${row.key}#${suffix}`; + next ??= rows.slice(0, index); + next.push({ ...row, key }); + seen.add(key); + continue; } - seen.add(key); + seen.add(row.key); + next?.push(row); } return next ?? rows; } diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts index b7fd30544..9aa42ecb6 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts @@ -24,6 +24,7 @@ import { } from "./turnReducer"; import type { HistoryApplyMode, + ManualCompactionResult, RetryAttemptRecord, TranscriptRow, TranscriptSnapshot, @@ -95,6 +96,7 @@ const EMPTY_SNAPSHOT: TranscriptSnapshot = { toolStatus: null, toolStatusIsCompaction: false, retryAttempts: EMPTY_RETRY_ATTEMPTS, + manualCompactionResult: null, needsHistoryRefresh: false, foldRevision: 0, revision: 0, @@ -192,6 +194,7 @@ export function createTranscriptStore(options?: { let toolStatus: string | null = null; let toolStatusIsCompaction = false; let retryAttempts: readonly RetryAttemptRecord[] = EMPTY_RETRY_ATTEMPTS; + let manualCompactionResult: ManualCompactionResult | null = null; let foldRevision = 0; let localTurnSeq = 0; // Idempotency cursor: the highest log seq already applied. Re-subscribe @@ -357,6 +360,7 @@ export function createTranscriptStore(options?: { toolStatus, toolStatusIsCompaction, retryAttempts, + manualCompactionResult, needsHistoryRefresh: rebaseDivergent || turns.some((turn) => turn.contentStale === true), foldRevision, revision: snapshot.revision + 1, @@ -1019,6 +1023,32 @@ export function createTranscriptStore(options?: { } return; } + case "manual_compaction_result": { + // 本分支是 switch 里唯一直读载荷形状的地方,必须与相邻 case 一样防御式 + // 解构:可靠 ingress journal 会重放本帧,缺字段的畸形帧若抛 TypeError 会 + // 在每次重订阅时复现并打断整条应用链。operationId 非字符串/空、status 不在 + // 白名单内、message 非字符串——一律降级(丢帧或空串),永不抛错。 + const rawOperationId = (event as { operationId?: unknown }).operationId; + const operationId = typeof rawOperationId === "string" ? rawOperationId.trim() : ""; + if (!operationId) return; + const rawStatus = (event as { status?: unknown }).status; + if ( + rawStatus !== "compacted" && + rawStatus !== "failed" && + rawStatus !== "busy" && + rawStatus !== "skipped" + ) { + return; + } + const rawMessage = (event as { message?: unknown }).message; + manualCompactionResult = { + operationId, + status: rawStatus, + message: typeof rawMessage === "string" ? rawMessage.trim() : "", + }; + schedule(true); + return; + } default: { applyDelta(event, runId); } diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts b/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts index 8057d1778..9f4ea9b6c 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts @@ -8,6 +8,7 @@ import { import { toolArgsProgress } from "@/lib/chat/toolPreview"; import { summarizeToolCall } from "@/lib/chat/uiMessages"; import { + type AssistantMeta, buildAssistantMeta, buildHostedSearchEntry, buildToolCallEntry, @@ -29,6 +30,20 @@ import type { ChatEvent } from "@/lib/gatewayTypes"; import type { Turn, TurnPhase } from "./types"; +// 合并 assistant meta:只用已定义字段覆盖,值为 undefined 的键一律跳过。 +// buildAssistantMeta 现已增量构建(不再物化 own-undefined 键),这里再加一道 +// 防御,保证未来任何生产者送来的 meta 都不会用 undefined 抹掉此前事件送达的 +// contextUsageTokens/usageTotalTokens 等锚点(issue #359 缺陷 #2)。 +function mergeAssistantMeta(base: AssistantMeta | undefined, patch: AssistantMeta): AssistantMeta { + const merged: AssistantMeta = { ...(base ?? {}) }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + (merged as Record)[key] = value; + } + } + return merged; +} + // Applies one assistant-side stream event to a turn. Every merge/dedup scan // is bounded by the turn (and, within it, the segment since the last // checkpoint/error entry) — the turn boundary replaces every "walk the tail @@ -335,6 +350,8 @@ function applyTokenEvent(turn: Turn, event: Extract 0 ? Math.floor(round) : undefined; -} - function normalizeLiveUploadedFile(value: unknown): PendingUploadedFile | null { const record = asNonArrayRecord(value); const relativePath = readString(record.relativePath ?? record.relative_path).trim(); @@ -397,20 +394,34 @@ export function buildAssistantMeta(params: { api?: unknown; stopReason?: unknown; usage?: unknown; + contextUsageTokens?: unknown; + contextRelevant?: unknown; }) { const usage = params.usage && typeof params.usage === "object" ? (params.usage as Usage) : undefined; - const meta: AssistantMeta = { - provider: readString(params.provider) || undefined, - model: readString(params.model) || undefined, - api: readString(params.api) || undefined, - stopReason: readString(params.stopReason) || undefined, - usage, - usageTotalTokens: getUsageTotalTokens(params.usage), - }; - - return Object.values(meta).some((value) => value !== undefined) ? meta : undefined; + // 增量构建:只 set 已定义的字段,绝不物化出 own-property undefined 键。后者在 + // `{ ...target.meta, ...meta }` 合并时会用 undefined 覆盖此前事件送达的 + // contextUsageTokens/usageTotalTokens 等锚点,导致用量环塌值(issue #359 缺陷 #2)。 + const meta: AssistantMeta = {}; + const provider = readString(params.provider) || undefined; + if (provider !== undefined) meta.provider = provider; + const model = readString(params.model) || undefined; + if (model !== undefined) meta.model = model; + const api = readString(params.api) || undefined; + if (api !== undefined) meta.api = api; + const stopReason = readString(params.stopReason) || undefined; + if (stopReason !== undefined) meta.stopReason = stopReason; + if (usage !== undefined) meta.usage = usage; + const usageTotalTokens = getUsageTotalTokens(params.usage); + if (usageTotalTokens !== undefined) meta.usageTotalTokens = usageTotalTokens; + const contextUsageTokens = positiveTokenCount(params.contextUsageTokens); + if (contextUsageTokens !== undefined) meta.contextUsageTokens = contextUsageTokens; + if (typeof params.contextRelevant === "boolean") { + meta.contextRelevant = params.contextRelevant; + } + + return Object.keys(meta).length > 0 ? meta : undefined; } export function normalizeCheckpointEntry(params: { @@ -439,17 +450,16 @@ export function normalizeCheckpointEntry(params: { typeof params.checkpoint?.coveredMessageCount === "number" ? params.checkpoint.coveredMessageCount : summaryMetaRecord.coveredMessageCount; - const coveredMessageCount = - typeof coveredMessageCountCandidate === "number" && - Number.isFinite(coveredMessageCountCandidate) && - coveredMessageCountCandidate > 0 - ? Math.floor(coveredMessageCountCandidate) - : 0; + const coveredMessageCount = positiveTokenCount(coveredMessageCountCandidate) ?? 0; const providerId = readString(generatedByRecord.providerId).trim() || "liveagent"; const model = readString(generatedByRecord.model).trim() || "summary"; const promptVersion = readString(generatedByRecord.promptVersion).trim() || undefined; const timestamp = readNumber(params.checkpoint?.timestamp) ?? readNumber(params.timestamp) ?? Date.now(); + const summaryStatsRecord = asRecord(summaryMetaRecord.stats); + const contextUsageTokens = positiveTokenCount( + params.checkpoint?.contextUsageTokens ?? summaryStatsRecord.contextTokensAfter, + ); return { id: `checkpoint-${summaryId}`, @@ -462,6 +472,7 @@ export function normalizeCheckpointEntry(params: { model, promptVersion, }, + contextUsageTokens, timestamp, }; } @@ -769,12 +780,20 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { const round = currentRound; const messageTimestamp = readMessageTimestamp(message.timestamp); const blocks = normalizeAssistantBlocks(message.content); + // 重建 meta 不带 contextRelevant 是有意为之(issue #359 缺陷 #8):桌面端只对 + // render-only 轮次(记忆抽取等)标 contextRelevant:false,而这类轮次仅经 + // appendRenderOnlyMessagesToConversation 进入 UI transcript.items 供展示, + // 从不写入持久化的 segment.messages(见 chatHistory.ts writeConversationRuntime)。 + // 因此历史 JSON 里根本不存在 render-only 轮次,也就没有可辨识标记可供重建; + // 用量环倒扫(deriveContextUsageTokens)不会误锚定在抽取请求的小 usage 上。 + // 若将来 render-only 轮次开始入历史 JSON,须在此按其标记重建 meta.contextRelevant。 const meta = buildAssistantMeta({ provider: message.provider, model: message.model, api: message.api, stopReason: message.stopReason, usage: message.usage, + contextUsageTokens: asRecord(message.liveAgentContextUsage).totalTokens, }); let textBuffer = ""; let metaEmitted = false; diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index eafcf228a..ea9a2fa53 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -1757,6 +1757,20 @@ export class GatewayWebSocketClient { ); } + // 用量环触发的手动压缩:中继到桌面端执行(受理即回包,进度与带 + // operationId 的终态分别经聊天流回传)。 + async chatQueueCompactNow( + conversationId: string, + operationId: string, + ): Promise { + return normalizeChatQueueResponse( + await this.requestWithRecovery("chat_queue.compact_now", { + conversation_id: conversationId, + request_json: JSON.stringify({ operationId }), + }), + ); + } + async chatQueueMove( conversationId: string, itemId: string, @@ -3667,6 +3681,7 @@ export type GatewayWebSocketClientLike = { chatQueueGet(conversationId: string): Promise; chatQueueGetItem(conversationId: string, itemId: string): Promise; chatQueueRunNow(conversationId: string, itemId: string): Promise; + chatQueueCompactNow(conversationId: string, operationId: string): Promise; chatQueueMove( conversationId: string, itemId: string, diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index 4360b0bf5..ecff816cf 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -57,6 +57,7 @@ export type ChatCheckpointPayload = { model?: string; promptVersion?: string; }; + contextUsageTokens?: number; }; export type ChatUserMessageEvent = { @@ -92,6 +93,8 @@ export type ChatEvent = ( api?: string; stopReason?: string; usage?: unknown; + contextUsageTokens?: number; + contextRelevant?: boolean; checkpoint?: ChatCheckpointPayload; conversation_id?: string; } @@ -149,6 +152,13 @@ export type ChatEvent = ( round?: number; conversation_id?: string; } + | { + type: "manual_compaction_result"; + operationId: string; + status: "compacted" | "failed" | "busy" | "skipped"; + message?: string; + conversation_id?: string; + } | { type: "error"; message: string; round?: number; conversation_id?: string } | ChatUserMessageEvent | ChatRebasedEvent diff --git a/crates/agent-gateway/web/src/styles.css b/crates/agent-gateway/web/src/styles.css index d5737793d..f655ab39e 100644 --- a/crates/agent-gateway/web/src/styles.css +++ b/crates/agent-gateway/web/src/styles.css @@ -4894,3 +4894,36 @@ html[data-liveagent-webui="gateway"] grid-template-columns: repeat(3, minmax(0, 1fr)); } } + +/* Confirm-action popover (context usage ring's manual-compact confirm): + * mirror of the GUI's enter/exit animation — grow out of the anchor, keyed + * off Base UI's data-starting-style / data-ending-style. */ +.confirm-action-popover-popup { + --confirm-popover-slide-y: -6px; + transform-origin: var(--transform-origin); + transition: + opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.18s cubic-bezier(0.16, 1, 0.3, 1); +} + +.confirm-action-popover-popup[data-side="top"] { + --confirm-popover-slide-y: 6px; +} + +.confirm-action-popover-popup[data-starting-style], +.confirm-action-popover-popup[data-ending-style] { + opacity: 0; + transform: translateY(var(--confirm-popover-slide-y)) scale(0.96); +} + +.confirm-action-popover-popup[data-ending-style] { + transition-duration: 0.12s; + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); + transform: translateY(calc(var(--confirm-popover-slide-y) / 2)) scale(0.98); +} + +@media (prefers-reduced-motion: reduce) { + .confirm-action-popover-popup { + transition: none; + } +} diff --git a/crates/agent-gateway/web/test/sidebar-transient-activity.test.mjs b/crates/agent-gateway/web/test/sidebar-transient-activity.test.mjs new file mode 100644 index 000000000..17920487f --- /dev/null +++ b/crates/agent-gateway/web/test/sidebar-transient-activity.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { mergeTransientSidebarRunningActivity } = loader.loadModule( + "@liveagent/ui/lib/sidebar/transientActivity.ts", +); +const gatewayAppSource = readFileSync(new URL("../src/app/GatewayApp.tsx", import.meta.url), "utf8"); + +test("manual compaction keeps its conversation and workspace running until terminal cleanup", () => { + const runningConversationIds = new Set(["other-conversation"]); + const runningProjectPathKeys = new Set(["/other/workspace"]); + // 向后兼容:单对象入参仍被接受。 + const merged = mergeTransientSidebarRunningActivity( + runningConversationIds, + runningProjectPathKeys, + { + conversationId: "conversation-1", + workdir: "/workspace/project/", + }, + ); + + assert.deepEqual([...merged.runningConversationIds], ["other-conversation", "conversation-1"]); + assert.deepEqual([...merged.runningProjectPathKeys], ["/other/workspace", "/workspace/project"]); + + const cleared = mergeTransientSidebarRunningActivity( + runningConversationIds, + runningProjectPathKeys, + null, + ); + assert.equal(cleared.runningConversationIds, runningConversationIds); + assert.equal(cleared.runningProjectPathKeys, runningProjectPathKeys); + + const clearedEmptyArray = mergeTransientSidebarRunningActivity( + runningConversationIds, + runningProjectPathKeys, + [], + ); + assert.equal(clearedEmptyArray.runningConversationIds, runningConversationIds); + assert.equal(clearedEmptyArray.runningProjectPathKeys, runningProjectPathKeys); +}); + +test("multiple manual compactions keep every pending conversation and workspace running (defect #3)", () => { + const runningConversationIds = new Set(["other-conversation"]); + const runningProjectPathKeys = new Set(["/other/workspace"]); + const merged = mergeTransientSidebarRunningActivity( + runningConversationIds, + runningProjectPathKeys, + [ + { conversationId: "conversation-1", workdir: "/workspace/one/" }, + { conversationId: "conversation-2", workdir: "/workspace/two/" }, + // null/undefined 条目被跳过。 + null, + undefined, + // 重复会话/工作区不重复计入。 + { conversationId: "conversation-1", workdir: "/workspace/one/" }, + ], + ); + + assert.deepEqual( + [...merged.runningConversationIds], + ["other-conversation", "conversation-1", "conversation-2"], + ); + assert.deepEqual( + [...merged.runningProjectPathKeys], + ["/other/workspace", "/workspace/one", "/workspace/two"], + ); +}); + +test("manual compaction pending is keyed per conversation, never a global singleton (defect #3)", () => { + // pending 按会话 id 键化:state + ref 经唯一 setter/clearer 同步写。 + assert.match( + gatewayAppSource, + /useState<\s*ReadonlyMap\s*>/, + ); + assert.match( + gatewayAppSource, + /const clearManualCompactPendingRequest = useCallback\(\s*\(conversationId: string, operationId: string\) => \{[\s\S]*?next\.delete\(conversationId\);/, + ); + // handleManualCompact 只在“同会话”已有 pending 时拒绝。 + assert.match( + gatewayAppSource, + /manualCompactPendingRef\.current\.has\(conversationIdValue\)/, + ); + // 受理拒绝(!accepted)按 (conversationId, operationId) 清 pending。 + assert.match( + gatewayAppSource, + /!response\.accepted &&\s*clearManualCompactPendingRequest\(conversationIdValue, operationId\) &&\s*isDisplayedConversation\(conversationIdValue\)/, + ); +}); + +test("manual compaction terminal settlement surfaces the result even for background conversations (defect #4)", () => { + // settle 无条件 setChatError(不再以 isDisplayedConversation 门控),使切走的 + // 会话压缩失败/跳过也能提示。 + assert.match( + gatewayAppSource, + /if \(!clearManualCompactPendingRequest\(targetConversationId, result\.operationId\)\) return;[\s\S]*?setChatError\(result\.message \|\| translate\(fallbackKey, settings\.locale\)\);/, + ); + assert.doesNotMatch( + gatewayAppSource, + /if \(isDisplayedConversation\(targetConversationId\)\) \{\s*setChatError\(result\.message/, + ); +}); diff --git a/crates/agent-gateway/web/test/transcript-rows.test.mjs b/crates/agent-gateway/web/test/transcript-rows.test.mjs index fc8ca1a5c..4b00f0862 100644 --- a/crates/agent-gateway/web/test/transcript-rows.test.mjs +++ b/crates/agent-gateway/web/test/transcript-rows.test.mjs @@ -115,6 +115,25 @@ test("buildTurnRows emits the user bubble before any assistant content, tagged w assert.equal(rows[1].turnKey, "req:c1"); }); +test("stream token metadata preserves authoritative context usage and render-only markers", () => { + let turn = createTurn({ key: "run:usage", runId: "run-usage" }); + turn = applyEventToTurn(turn, { + type: "token", + text: "memory status", + round: 2, + usage: { totalTokens: 10_000 }, + contextUsageTokens: 150_000, + contextRelevant: false, + }); + const rows = buildTurnRows(turn); + + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, "assistant"); + assert.equal(rows[0].rounds[0].meta.usageTotalTokens, 10_000); + assert.equal(rows[0].rounds[0].meta.contextUsageTokens, 150_000); + assert.equal(rows[0].rounds[0].meta.contextRelevant, false); +}); + test("interleaved thinking and tool results keep one assistant row and stable block identities", () => { const prefixEntries = [ { id: "think-1", kind: "thinking", text: "reasoning", round: 1 }, @@ -243,6 +262,33 @@ test("dedupeRowKeys suffixes collisions deterministically without touching uniqu assert.equal(dedupeRowKeys(untouched), untouched, "no copy when keys are already unique"); }); +test("dedupeRowKeys drops colliding checkpoint rows instead of renaming them", () => { + // 检查点 id 是内容身份(checkpoint-):history 区与手动压缩 turn + // 各持一份时是同一张逻辑卡片,改名保留会渲染出重复检查点。 + const checkpoint = (origin) => ({ + key: "checkpoint-sum-1", + origin, + kind: "checkpoint", + content: "summary", + summaryId: "sum-1", + coveredMessageCount: 4, + generatedBy: { providerId: "p", model: "m" }, + timestamp: 1, + }); + const rows = [ + checkpoint("history"), + { key: "a", origin: "history", kind: "error", text: "1" }, + checkpoint("stream"), + { key: "a", origin: "stream", kind: "error", text: "2" }, + ]; + const deduped = dedupeRowKeys(rows); + assert.deepEqual( + deduped.map((row) => `${row.kind}:${row.key}`), + ["checkpoint:checkpoint-sum-1", "error:a", "error:a#2"], + "checkpoint duplicate dropped (first copy wins), non-checkpoint still renamed", + ); +}); + // --------------------------------------------------------------------------- // Deterministic history parse ids @@ -285,6 +331,48 @@ test("parseHistoryMessagesJson yields identical ids across reparses", () => { assert.equal(new Set(dupIds.map((entry) => entry.id)).size, 2, "identical prompts get distinct ids"); }); +test("persisted checkpoints retain the post-compaction context token snapshot", () => { + const entries = parseHistoryMessagesJson( + JSON.stringify([ + { + role: "summary", + id: "summary-1", + content: "compacted facts", + summaryMeta: { + coveredMessageCount: 12, + generatedBy: { providerId: "codex", model: "gpt-test" }, + stats: { sourceMessageCount: 12, contextTokensAfter: 43_210 }, + }, + }, + ]), + ); + assert.equal(entries[0].contextUsageTokens, 43_210); + const rows = buildRowsFromEntries(entries, "history"); + assert.equal(rows[0].contextUsageTokens, 43_210); +}); + +test("persisted assistant messages retain the desktop context usage snapshot", () => { + const entries = parseHistoryMessagesJson( + JSON.stringify([ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + provider: "openai", + model: "gpt-test", + api: "openai-responses", + stopReason: "stop", + usage: { totalTokens: 10_000 }, + liveAgentContextUsage: { totalTokens: 150_000, fixedTokens: 25_000 }, + timestamp: 1, + }, + ]), + ); + const rows = buildRowsFromEntries(entries, "history"); + + assert.equal(rows[0].kind, "assistant"); + assert.equal(rows[0].rounds[0].meta.contextUsageTokens, 150_000); +}); + test("thinking-first persisted replies emit a meta carrier that rows suppress", () => { const raw = JSON.stringify([ { role: "user", id: "m1", content: "查询" }, diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index 6e4e2f423..85a4be3a0 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -87,6 +87,119 @@ function messageRef(messageId, messageIndex = 0) { }; } +test("manual compaction terminal events retain status and stay conversation-scoped", () => { + const targetStore = createTranscriptStore(); + const otherStore = createTranscriptStore(); + const statuses = ["compacted", "failed", "busy", "skipped"]; + + for (const [index, status] of statuses.entries()) { + targetStore.applyEvent({ + type: "manual_compaction_result", + conversation_id: "conv-1", + seq: index + 1, + operationId: `operation-${index}`, + status, + message: status === "compacted" ? undefined : `message-${status}`, + }); + targetStore.flush(); + assert.deepEqual(targetStore.getSnapshot().manualCompactionResult, { + operationId: `operation-${index}`, + status, + message: status === "compacted" ? "" : `message-${status}`, + }); + } + + assert.equal(otherStore.getSnapshot().manualCompactionResult, null); +}); + +test("manual compaction terminal frames tolerate malformed payloads (defect #1)", () => { + const store = createTranscriptStore(); + // 唯一直读载荷形状的分支必须防御式解构:可靠 ingress journal 会重放本帧,缺 + // 字段/畸形帧一旦抛错会在每次重订阅时复现。以下畸形帧全部丢弃且绝不抛错。 + assert.doesNotThrow(() => { + // 无 operationId。 + store.applyEvent({ type: "manual_compaction_result", conversation_id: "conv-1", seq: 1 }); + // operationId 非字符串。 + store.applyEvent({ + type: "manual_compaction_result", + conversation_id: "conv-1", + seq: 2, + operationId: 123, + status: "failed", + }); + // status 不在白名单。 + store.applyEvent({ + type: "manual_compaction_result", + conversation_id: "conv-1", + seq: 3, + operationId: "op-bogus-status", + status: "bogus", + }); + // operationId 仅空白。 + store.applyEvent({ + type: "manual_compaction_result", + conversation_id: "conv-1", + seq: 4, + operationId: " ", + status: "failed", + }); + store.flush(); + }); + assert.equal(store.getSnapshot().manualCompactionResult, null); + + // 合法帧仍正常受理;message 非字符串降级为空串。 + store.applyEvent({ + type: "manual_compaction_result", + conversation_id: "conv-1", + seq: 5, + operationId: "op-ok", + status: "failed", + message: 42, + }); + store.flush(); + assert.deepEqual(store.getSnapshot().manualCompactionResult, { + operationId: "op-ok", + status: "failed", + message: "", + }); +}); + +test("assistant meta merge never wipes an existing anchor with a later own-undefined key (defect #2)", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "hello")); + store.applyEvent(runStarted("run-1", 2)); + // 首帧携带 contextUsageTokens 锚点。 + store.applyEvent({ + type: "token", + conversation_id: "conv-1", + run_id: "run-1", + seq: 3, + round: 0, + text: "answer ", + contextUsageTokens: 150_000, + }); + store.flush(); + + // 同轮后续帧带其他 meta 字段但不带 contextUsageTokens:旧代码会用 own-undefined + // 键把锚点抹掉;修复后锚点保留,新字段并入。 + store.applyEvent({ + type: "token", + conversation_id: "conv-1", + run_id: "run-1", + seq: 4, + round: 0, + text: "more", + model: "claude-sonnet", + }); + store.flush(); + + const snapshot = store.getSnapshot(); + const assistantRow = allRows(snapshot).find((row) => row.kind === "assistant"); + assert.ok(assistantRow, "expected an assistant row"); + assert.equal(assistantRow.rounds[0].meta.contextUsageTokens, 150_000); + assert.equal(assistantRow.rounds[0].meta.model, "claude-sonnet"); +}); + test("run lifecycle: reply renders in the live flow and folds at the next run_started", () => { const store = createTranscriptStore(); store.applyEvent(userMessage("run-1", 1, "hello")); @@ -914,7 +1027,13 @@ test("reset sync rebuilds the active turn from a runtime snapshot", () => { runId: "run-2", revision: 5, entriesJson: JSON.stringify([ - { id: "snap-1", kind: "assistant", text: "rebuilt from snapshot", round: 0 }, + { + id: "snap-1", + kind: "assistant", + text: "rebuilt from snapshot", + round: 0, + meta: { contextUsageTokens: 150_000, contextRelevant: false }, + }, ]), toolStatus: "Vibing", toolStatusIsCompaction: false, @@ -930,6 +1049,9 @@ test("reset sync rebuilds the active turn from a runtime snapshot", () => { const text = allRows(snapshot).map((row) => rowText(row)).join(""); assert.match(text, /rebuilt from snapshot/); assert.doesNotMatch(text, /will be lost/); + const assistantRow = allRows(snapshot).find((row) => row.kind === "assistant"); + assert.equal(assistantRow.rounds[0].meta.contextUsageTokens, 150_000); + assert.equal(assistantRow.rounds[0].meta.contextRelevant, false); }); test("active sync trims a history-first copy of the running exchange", () => { @@ -2354,3 +2476,79 @@ test("ref-bearing replay converges regardless of history/replay arrival order", "replay-first converges to the final version", ); }); + +test("manual compaction checkpoint stays a single card after the next exchange's history merge", () => { + const { parseHistoryMessagesJson } = loader.loadModule("src/lib/chatUi.ts"); + const store = createTranscriptStore(); + + // 手动压缩 run:无 user_message,只有 checkpoint token(gatewayBridgeEvents + // 的 queueCheckpoint 形状)+ historyRequired 终态。 + store.applyEvent(runStarted("run-compact", 1)); + store.applyEvent({ + type: "token", + conversation_id: "conv-1", + run_id: "run-compact", + seq: 2, + text: "summary body", + provider: "liveagent", + model: "summary", + api: "liveagent-compaction", + checkpoint: { + summaryId: "sum-1", + segmentIndex: 1, + coveredMessageCount: 4, + timestamp: 1000, + generatedBy: { providerId: "anthropic", model: "claude", promptVersion: "v1" }, + contextUsageTokens: 1234, + }, + }); + store.applyEvent( + runFinished("run-compact", 3, "completed", { + content_complete: false, + history_required: true, + entries_json: "[]", + }), + ); + store.flush(); + assert.equal( + allRows(store.getSnapshot()).filter((row) => row.kind === "checkpoint").length, + 1, + "one checkpoint card right after compaction", + ); + + // 下一轮发送落定。 + store.applyEvent(runStarted("run-2", 4)); + store.applyEvent(userMessage("run-2", 5, "next prompt", { message_id: "user-2" })); + store.applyEvent(token("run-2", 6, "reply")); + store.applyEvent(runFinished("run-2", 7)); + store.flush(); + + // 历史刷新把同一检查点(同 summaryId)并进折叠区:压缩 turn 无用户消息 + // 锚点、无法被对齐覆盖,其检查点副本必须在行构建层被内容身份去重。 + const historyEntries = parseHistoryMessagesJson( + JSON.stringify([ + { + role: "summary", + id: "sum-1", + content: "summary body", + timestamp: 1000, + summaryMeta: { + coveredMessageCount: 4, + generatedBy: { providerId: "anthropic", model: "claude", promptVersion: "v1" }, + stats: { sourceMessageCount: 4, contextTokensAfter: 1234 }, + }, + }, + { role: "user", id: "user-2", content: "next prompt", timestamp: 2000 }, + { role: "assistant", content: "reply", timestamp: 3000 }, + ]), + ); + for (const mode of ["enrich", "replace"]) { + store.applyHistorySnapshot(historyEntries, { mode }); + store.flush(); + const snapshot = store.getSnapshot(); + const checkpoints = allRows(snapshot).filter((row) => row.kind === "checkpoint"); + assert.equal(checkpoints.length, 1, `${mode}: duplicate checkpoint cards`); + assert.equal(checkpoints[0].key, "checkpoint-sum-1", `${mode}: stable content-identity key`); + assertUniqueKeys(snapshot); + } +}); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 93a830a30..59507d52a 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -217,6 +217,19 @@ export const translations: Record> = { "chat.changedFiles.expand": "展开文件列表", "chat.compactingContext": "正在压缩上下文", "chat.compactingContextWait": "正在压缩上下文,请稍候...", + "chat.contextCheckpoint.title": "上下文检查点", + "chat.contextCheckpoint.messageCount": "{count} 条消息", + "chat.contextCheckpoint.compressed": "已压缩", + "chat.manualCompactTitle": "手动压缩上下文?", + "chat.manualCompactDescription": "将历史消息折叠为摘要检查点,释放上下文空间。", + "chat.manualCompactConfirm": "压缩", + "chat.manualCompactRejected": "桌面端暂无法压缩(会话运行中或正在压缩)", + "chat.manualCompactBelowThreshold": "上下文占用尚未达到 50%,暂不需要压缩", + "chat.manualCompactEmpty": "当前会话没有可压缩的上下文", + "chat.manualCompactUnavailable": "当前模型缺少上下文配置,无法压缩", + "chat.manualCompactFailed": "上下文压缩失败,请稍后重试", + "chat.manualCompactCancelled": "已取消压缩", + "chat.manualCompactTimedOut": "上下文压缩未返回结果,请确认桌面端在线后重试", "chat.editMessage": "编辑消息", "chat.cancel": "取消", "chat.queue.title": "等待队列 {count}", @@ -2508,6 +2521,21 @@ export const translations: Record> = { "chat.changedFiles.expand": "Expand file list", "chat.compactingContext": "Compressing context", "chat.compactingContextWait": "Compressing context, please wait...", + "chat.contextCheckpoint.title": "Context Checkpoint", + "chat.contextCheckpoint.messageCount": "{count} msgs", + "chat.contextCheckpoint.compressed": "Compressed", + "chat.manualCompactTitle": "Compact context manually?", + "chat.manualCompactDescription": + "Folds earlier messages into a summary checkpoint to free context space.", + "chat.manualCompactConfirm": "Compact", + "chat.manualCompactRejected": "Desktop cannot compact right now (busy or already compacting)", + "chat.manualCompactBelowThreshold": "Context usage is below 50%; compaction is not needed yet", + "chat.manualCompactEmpty": "This conversation has no context to compact", + "chat.manualCompactUnavailable": "Context compaction is unavailable for the current model", + "chat.manualCompactFailed": "Context compaction failed. Please try again", + "chat.manualCompactCancelled": "Compaction cancelled", + "chat.manualCompactTimedOut": + "Context compaction did not return a result. Check the desktop connection and try again", "chat.editMessage": "Edit Message", "chat.cancel": "Cancel", "chat.queue.title": "Queue {count}", diff --git a/crates/agent-gui/src/lib/chat/compaction/contextUsageMetadata.ts b/crates/agent-gui/src/lib/chat/compaction/contextUsageMetadata.ts new file mode 100644 index 000000000..b67f15675 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/compaction/contextUsageMetadata.ts @@ -0,0 +1,36 @@ +import type { AssistantMessage, Message } from "@earendil-works/pi-ai"; + +import { positiveTokenCount } from "@liveagent/ui/lib/chat/contextUsage"; + +export const LIVEAGENT_CONTEXT_USAGE_FIELD = "liveAgentContextUsage"; + +export type MessageContextUsage = { + totalTokens: number; + fixedTokens: number; +}; + +type MessageWithContextUsage = Message & { + [LIVEAGENT_CONTEXT_USAGE_FIELD]?: unknown; +}; + +export function readMessageContextUsage(message: Message): MessageContextUsage | undefined { + const raw = (message as MessageWithContextUsage)[LIVEAGENT_CONTEXT_USAGE_FIELD]; + if (!raw || typeof raw !== "object") return undefined; + const record = raw as Record; + const totalTokens = positiveTokenCount(record.totalTokens); + const fixedTokens = positiveTokenCount(record.fixedTokens) ?? 0; + return totalTokens === undefined ? undefined : { totalTokens, fixedTokens }; +} + +// 印章写入的唯一入口。不变量:totalTokens 必须是 usage 派生的权威值(绝不写 +// 估算)——印章随会话持久化且读取侧优先于 message.usage,估算一旦盖章便永久 +// 遮蔽后到的真实读数。 +export function writeAssistantContextUsage( + message: AssistantMessage, + usage: MessageContextUsage, +): void { + (message as MessageWithContextUsage)[LIVEAGENT_CONTEXT_USAGE_FIELD] = { + totalTokens: Math.max(1, Math.floor(usage.totalTokens)), + fixedTokens: Math.max(0, Math.floor(usage.fixedTokens)), + }; +} diff --git a/crates/agent-gui/src/lib/chat/compaction/controller.ts b/crates/agent-gui/src/lib/chat/compaction/controller.ts index 99a9fcef3..4769a0575 100644 --- a/crates/agent-gui/src/lib/chat/compaction/controller.ts +++ b/crates/agent-gui/src/lib/chat/compaction/controller.ts @@ -1,4 +1,9 @@ import type { Context, UserMessage } from "@earendil-works/pi-ai"; +import { + canManualCompact, + contextUsageRatio, + positiveTokenCount, +} from "@liveagent/ui/lib/chat/contextUsage"; import type { StreamDebugLogger } from "../../debug/agentDebug"; import type { ProviderId } from "../../settings"; @@ -22,9 +27,10 @@ import { PRUNE_FALLBACK_NOTICE, } from "./statusText"; import { type CompleteAssistantFn, createCompactionAbortError } from "./summarizer"; -import { TokenLedger } from "./tokenLedger"; +import { deriveContextTokens, TokenLedger } from "./tokenLedger"; import type { CompactionDecision, + CompactionDecisionReason, CompactionIntent, CompactionStatus, CompactionTrigger, @@ -44,7 +50,7 @@ export type CompactionSinks = { applyStateMidRun?: (state: ConversationViewState) => void; publishStatus?: (status: CompactionStatus) => void; setBridgeToolStatus?: (status: string | null, isCompaction?: boolean) => void; - queueCheckpoint?: (state: ConversationViewState) => void; + queueCheckpoint?: (state: ConversationViewState, contextUsageTokens: number) => void; persist?: (state: ConversationViewState) => Promise; restoreComposer?: ( composerText: string | undefined, @@ -88,8 +94,50 @@ export type CompactionTurnBinding = { export type CompactionDuringRunResult = { context: Context | null; shouldDisableProtection: boolean; + // 本次调用的显式结果通道。statusPhase 是控制器生命周期字段(跨操作残留、 + // 决策拒绝时不 publish),任何调用方都不得用它反推单次调用的结果。 + outcome: "compacted" | "skipped" | "failed"; + // skipped 时携带决策拒绝原因;无 binding 的空跑没有决策、不带 reason。 + reason?: CompactionDecisionReason; +}; + +export type ManualCompactionOutcome = + | { status: "compacted" | "busy" } + | { status: "failed"; aborted?: boolean } + | { status: "skipped"; reason: CompactionDecisionReason }; + +export type ManualContextUsageSnapshot = { + totalTokens?: number; + fixedTokens?: number; }; +function withActiveSummaryContextTokens( + state: ConversationViewState, + contextUsageTokens: number, +): ConversationViewState { + const segmentIndex = state.activeSegmentIndex; + const segment = state.segments[segmentIndex]; + if (!segment?.summary) return state; + const nextSegment = { + ...segment, + summary: { + ...segment.summary, + summaryMeta: { + ...segment.summary.summaryMeta, + stats: { + ...(segment.summary.summaryMeta.stats ?? { + sourceMessageCount: segment.summary.summaryMeta.coveredMessageCount, + }), + contextTokensAfter: contextUsageTokens, + }, + }, + }, + }; + const segments = state.segments.slice(); + segments[segmentIndex] = nextSegment; + return { ...state, segments }; +} + type RollbackSnapshot = { state: ConversationViewState; composerText?: string; @@ -134,9 +182,60 @@ export class CompactionController { } } + // 压缩成功后的统一收尾(pre-send 与 during-run 共用同一顺序不变量): + // checkpoint 上下文估值 → 写回 summary stats → 持久化屏障 → 回滚快照失效 → + // apply 落地 → completed 终态 → checkpoint 入队。tools 必须与真实请求同参, + // 否则 contextTokensAfter 系统性少算工具重量;fixedTokens 用持久化的动态 + // 开销校准估值(undefined 时 deriveContextTokens 内部回退 system+tools 估算)。 + private async finalizeCheckpoint(params: { + binding: CompactionTurnBinding; + trigger: CompactionTrigger; + state: ConversationViewState; + newSegmentIndex: number; + tools?: Context["tools"]; + buildOptions: ContextBuildOptions; + fixedTokens?: number; + // 在 persist 屏障之后、completed 终态之前同步执行的状态落地钩子。 + apply: (checkpointState: ConversationViewState) => void; + }): Promise<{ checkpointState: ConversationViewState; checkpointTokens: number }> { + const checkpointContext = params.binding.buildPreparedContext( + params.state, + params.tools, + params.buildOptions, + ); + const checkpointTokens = deriveContextTokens(checkpointContext, { + fixedTokens: params.fixedTokens, + }); + const checkpointState = withActiveSummaryContextTokens(params.state, checkpointTokens); + await this.persistCheckpoint(params.binding, checkpointState); + this.rollbackSnapshot = null; + params.apply(checkpointState); + this.settleCompleted(params.trigger, params.newSegmentIndex); + params.binding.sinks.queueCheckpoint?.(checkpointState, checkpointTokens); + return { checkpointState, checkpointTokens }; + } + beginRequest(context: Context, state: ConversationViewState) { this.ledger.rebase(context); this.updateTurnMeta(state); + return this.ledger.total(); + } + + observeContextMessages(messages: readonly Context["messages"][number][]) { + this.ledger.addMessages(messages); + return this.ledger.total(); + } + + get contextUsageTokens() { + const totalTokens = this.ledger.total(); + return totalTokens > 0 ? totalTokens : undefined; + } + + get contextUsageSnapshot(): ManualContextUsageSnapshot | undefined { + const snapshot = this.ledger.snapshot(); + return snapshot.totalTokens > 0 + ? { totalTokens: snapshot.totalTokens, fixedTokens: snapshot.fixedTokens } + : undefined; } // O(1):账本读数 + 流式增量估算 + 纯决策,无状态构建、无序列化。 @@ -213,12 +312,20 @@ export class CompactionController { complete: binding.complete, }); - await this.persistCheckpoint(binding, outcome.state); - this.rollbackSnapshot = null; - const appliedState = presend.composeAppliedState(outcome.state); - binding.sinks.applyState?.(appliedState); - this.settleCompleted("pre-send", outcome.newSegmentIndex); - binding.sinks.queueCheckpoint?.(outcome.state); + // apply 在 finalizeCheckpoint 内同步执行,appliedState 在其返回前必已赋值。 + let appliedState!: ConversationViewState; + await this.finalizeCheckpoint({ + binding, + trigger: "pre-send", + state: outcome.state, + newSegmentIndex: outcome.newSegmentIndex, + tools: params.tools, + buildOptions, + apply: (checkpointState) => { + appliedState = presend.composeAppliedState(checkpointState); + binding.sinks.applyState?.(appliedState); + }, + }); this.notePostCompactionPressure( binding.buildPreparedContext(appliedState, params.tools, buildOptions), appliedState, @@ -255,10 +362,13 @@ export class CompactionController { tools?: Context["tools"]; includeAbortedMessages?: boolean; includeUploadedFilesMetadata?: boolean; + // manual 触发透传给决策:跳过阈值/冷却,硬守卫不受影响。 + bypassThresholdAndCooldown?: boolean; + manualContextUsage?: ManualContextUsageSnapshot; }): Promise { const binding = this.binding; if (!binding) { - return { context: null, shouldDisableProtection: false }; + return { context: null, shouldDisableProtection: false, outcome: "skipped" }; } // 覆盖"mid-stream abort 后、summarizer 启动前"用户恰好点停止的间隙。 if (binding.cancellation.userStop.signal.aborted) { @@ -285,7 +395,10 @@ export class CompactionController { let workingState = params.state; let pruned: PruneConversationResult | null = null; - if (shouldPruneBeforeCompaction(this.pressure, now)) { + // manual(空闲触发)不做前置 prune:prune 是运行中泄压手段,空闲路径没有 + // 后续 persist 兜底,落地未持久化的剪枝状态会造成内存/磁盘分叉;同时保证 + // 执行路径与探针(同样不 prune)对同一状态做决策,消除两者分歧。 + if (params.trigger !== "manual" && shouldPruneBeforeCompaction(this.pressure, now)) { const attempt = pruneConversationState(workingState, resolvePruneOptions(this.pressure)); if (attempt.applied) { pruned = attempt; @@ -297,9 +410,18 @@ export class CompactionController { !pruned && params.budgetContext ? params.budgetContext : binding.buildPreparedContext(workingState, params.tools, buildOptions); - this.ledger.rebase(budgetContext); + const manualFixedTokens = params.manualContextUsage?.fixedTokens; + // rebase 内部校验 fixedTokens(非法/undefined 回退估算),无需在调用点分叉。 + this.ledger.rebase(budgetContext, { fixedTokens: manualFixedTokens }); this.updateTurnMeta(workingState); - const decision = this.decide("protection", this.ledger.total(), now); + // manual 是空闲时的从容压缩,走 optimization 口径;运行中触发保持 protection。 + const intent: CompactionIntent = params.trigger === "manual" ? "optimization" : "protection"; + const totalTokens = + positiveTokenCount(params.manualContextUsage?.totalTokens) ?? this.ledger.total(); + const decision = + params.trigger === "manual" + ? this.decideManual(totalTokens, now) + : this.decide(intent, totalTokens, now, params.bypassThresholdAndCooldown); this.logDecision(decision); if (!decision.shouldCompact) { @@ -308,14 +430,23 @@ export class CompactionController { return { context: buildFallbackContext(pruned.state), shouldDisableProtection: false, + outcome: "skipped", + reason: decision.reason, }; } return params.trigger === "mid-stream" ? { context: buildFallbackContext(workingState), shouldDisableProtection: true, + outcome: "skipped", + reason: decision.reason, } - : { context: null, shouldDisableProtection: false }; + : { + context: null, + shouldDisableProtection: false, + outcome: "skipped", + reason: decision.reason, + }; } this.rollbackSnapshot = { state: params.state, persistOnRollback: true }; @@ -326,7 +457,7 @@ export class CompactionController { try { const outcome = await runCompaction({ state: workingState, - intent: "protection", + intent, contextTokens: decision.totalTokens, threshold: decision.threshold, providerId: binding.providerId, @@ -337,35 +468,57 @@ export class CompactionController { complete: binding.complete, }); - await this.persistCheckpoint(binding, outcome.state); - this.rollbackSnapshot = null; - binding.sinks.applyStateMidRun?.(outcome.state); - this.settleCompleted(params.trigger, outcome.newSegmentIndex); - binding.sinks.queueCheckpoint?.(outcome.state); + const { checkpointState } = await this.finalizeCheckpoint({ + binding, + trigger: params.trigger, + state: outcome.state, + newSegmentIndex: outcome.newSegmentIndex, + tools: params.tools, + buildOptions, + fixedTokens: manualFixedTokens, + apply: (state) => binding.sinks.applyStateMidRun?.(state), + }); const resumeMessage = createSyntheticContinueUserMessage( (outcome.checkpointMessage.timestamp ?? now) + 1, ); - const resumeContext = binding.buildResumeContext(outcome.state, resumeMessage, params.tools, { - includeUploadedFilesMetadata: params.includeUploadedFilesMetadata, - }); - this.notePostCompactionPressure(resumeContext, outcome.state, decision.threshold); - return { context: resumeContext, shouldDisableProtection: false }; + const resumeContext = binding.buildResumeContext( + checkpointState, + resumeMessage, + params.tools, + { + includeUploadedFilesMetadata: params.includeUploadedFilesMetadata, + }, + ); + this.notePostCompactionPressure( + resumeContext, + checkpointState, + decision.threshold, + manualFixedTokens, + ); + return { context: resumeContext, shouldDisableProtection: false, outcome: "compacted" }; } catch (error) { if (this.isAbortOutcome(scope.controller.signal, error)) { throw error; } this.rollbackSnapshot = null; - const fallback = - pruned ?? pruneConversationState(workingState, resolvePruneOptions(this.pressure)); - if (fallback.applied) { - binding.sinks.applyStateMidRun?.(fallback.state); - this.settleFailed(params.trigger, PRUNE_FALLBACK_NOTICE); - binding.sinks.setBridgeToolStatus?.(buildPruneFallbackStatus(fallback.prunedMessageCount)); - return { - context: buildFallbackContext(fallback.state), - shouldDisableProtection: false, - }; + // manual 面向空闲会话:没有后续轮次消费 fallback context,prune 结果也 + // 不会被持久化(一旦 apply 即内存与磁盘分叉),失败时必须原样保留会话。 + if (params.trigger !== "manual") { + const fallback = + pruned ?? pruneConversationState(workingState, resolvePruneOptions(this.pressure)); + if (fallback.applied) { + binding.sinks.applyStateMidRun?.(fallback.state); + this.settleFailed(params.trigger, PRUNE_FALLBACK_NOTICE); + binding.sinks.setBridgeToolStatus?.( + buildPruneFallbackStatus(fallback.prunedMessageCount), + ); + return { + context: buildFallbackContext(fallback.state), + shouldDisableProtection: false, + outcome: "failed", + }; + } } this.settleFailed( params.trigger, @@ -375,8 +528,9 @@ export class CompactionController { ? { context: buildFallbackContext(workingState), shouldDisableProtection: true, + outcome: "failed", } - : { context: null, shouldDisableProtection: false }; + : { context: null, shouldDisableProtection: false, outcome: "failed" }; } finally { scope.release(); this.inFlight = false; @@ -384,6 +538,91 @@ export class CompactionController { } } + /** + * 用户手动触发的压缩(用量环 → 确认)。仅限空闲:已有轮次绑定或压缩在飞 + * 返回 "busy"。临时绑定一轮复用 compactDuringRun 主流程;决策跳过自动阈值 + * 与冷却,但仍强制执行共享的 50% 手动门槛以及 disabled / no-active-messages + * 等硬守卫(守卫不过返回 "skipped")。 + */ + async compactManually( + binding: Omit, + state: ConversationViewState, + contextUsage?: ManualContextUsageSnapshot, + options?: { + // 与真实请求同参的工具集:checkpoint 估值缺了工具重量会系统性偏低。 + tools?: Context["tools"]; + // 探针通过、真正开始压缩前同步调用恰好一次(skip / busy 不触发)。 + onProceed?: () => void; + }, + ): Promise { + if (this.binding || this.inFlight) return { status: "busy" }; + this.bindTurn(binding); + try { + const probe = this.probeManualDecision(binding, state, contextUsage, options?.tools); + if (!probe.shouldCompact) { + // in-flight 已被入口 busy 检查排除(bindTurn 刚复位 inFlight),探针 + // 拒绝只剩 disabled / no-active-messages / below-manual-threshold 等硬守卫。 + return { status: "skipped", reason: probe.reason }; + } + options?.onProceed?.(); + const result = await this.compactDuringRun({ + trigger: "manual", + state, + tools: options?.tools, + manualContextUsage: contextUsage, + }); + // 只信本次调用的显式 outcome:statusPhase 可能残留上一次压缩的终态, + // 而内层二次裁决 skip 时不 publish 任何状态。 + switch (result.outcome) { + case "compacted": + return { status: "compacted" }; + case "skipped": + // binding 恒存在,内层 skip 必带决策 reason;回退仅为类型完备。 + return { status: "skipped", reason: result.reason ?? "disabled" }; + default: + return { status: "failed" }; + } + } catch { + // 中止或意外异常:走统一善后(回滚快照 / running 态复位 idle)。 + await this.handleTurnAbort(); + return binding.cancellation.userStop.signal.aborted + ? { status: "failed", aborted: true } + : { status: "failed" }; + } finally { + this.unbindTurn(); + } + } + + // 手动压缩的前置探针:跑一次与执行路径同口径的决策,把手动 50% 门槛及 + // disabled 等硬守卫挡在 publishRunning 之前。读数用局部临时账本计算—— + // 共享账本是用量环的读数真源,被拒的探测不得在其上留下任何残留。 + private probeManualDecision( + binding: Omit, + state: ConversationViewState, + contextUsage?: ManualContextUsageSnapshot, + tools?: Context["tools"], + ) { + const probeLedger = new TokenLedger(); + probeLedger.rebase(binding.buildPreparedContext(state, tools), { + fixedTokens: contextUsage?.fixedTokens, + }); + // turnMeta 是按 state 的幂等派生(decide 的硬守卫需要),更新无残留风险。 + this.updateTurnMeta(state); + return this.decideManual( + positiveTokenCount(contextUsage?.totalTokens) ?? probeLedger.total(), + Date.now(), + ); + } + + private decideManual(totalTokens: number, now: number): CompactionDecision { + const decision = this.decide("optimization", totalTokens, now, true); + if (!decision.shouldCompact) return decision; + if (canManualCompact(contextUsageRatio(decision.totalTokens, decision.contextWindow))) { + return decision; + } + return { ...decision, shouldCompact: false, reason: "below-manual-threshold" }; + } + // 用户中止后的统一善后:有快照则回滚(恢复状态/输入框/可选持久化)并返回 true。 async handleTurnAbort(): Promise { const binding = this.binding; @@ -423,7 +662,12 @@ export class CompactionController { }; } - private decide(intent: CompactionIntent, totalTokens: number, now = Date.now()) { + private decide( + intent: CompactionIntent, + totalTokens: number, + now = Date.now(), + bypassThresholdAndCooldown?: boolean, + ) { const binding = this.binding; if (!binding) { throw new Error("compaction decision requested without an active turn binding"); @@ -440,6 +684,7 @@ export class CompactionController { pressure: this.pressure, inFlight: this.inFlight, now, + bypassThresholdAndCooldown, }); } @@ -447,8 +692,9 @@ export class CompactionController { contextAfter: Context, stateAfter: ConversationViewState, threshold: number, + fixedTokens?: number, ) { - this.ledger.rebase(contextAfter); + this.ledger.rebase(contextAfter, { fixedTokens }); this.updateTurnMeta(stateAfter); this.pressure = notePressureAfterCompaction(this.pressure, { totalTokensAfter: this.ledger.total(), diff --git a/crates/agent-gui/src/lib/chat/compaction/policy.ts b/crates/agent-gui/src/lib/chat/compaction/policy.ts index 6efd7af98..23a28c6f0 100644 --- a/crates/agent-gui/src/lib/chat/compaction/policy.ts +++ b/crates/agent-gui/src/lib/chat/compaction/policy.ts @@ -128,6 +128,9 @@ export function decideCompaction(params: { pressure: CompactionPressure; inFlight: boolean; now: number; + // 手动触发:用户明确要压,跳过阈值与冷却两个短路;disabled / + // no-active-messages / in-flight 硬守卫仍然生效。 + bypassThresholdAndCooldown?: boolean; }): CompactionDecision { const contextWindow = Math.max(0, Math.floor(params.modelConfig?.contextWindow ?? 0)); const maxOutputToken = Math.max(0, Math.floor(params.modelConfig?.maxOutputToken ?? 0)); @@ -171,12 +174,13 @@ export function decideCompaction(params: { return { ...base, shouldCompact: false, reason: "in-flight", threshold, thresholdMode }; } - if (base.totalTokens < threshold) { + if (!params.bypassThresholdAndCooldown && base.totalTokens < threshold) { return { ...base, shouldCompact: false, reason: "below-threshold", threshold, thresholdMode }; } // 冷却窗只拦"刚压缩完又立即越阈值"的超大单轮;正常自触发已被账本重置阻断。 if ( + !params.bypassThresholdAndCooldown && params.lastCompactionAt > 0 && params.now - params.lastCompactionAt < MIN_COMPACTION_INTERVAL_MS && params.userMessageCount < MIN_COMPACTION_USER_MESSAGES diff --git a/crates/agent-gui/src/lib/chat/compaction/tokenLedger.ts b/crates/agent-gui/src/lib/chat/compaction/tokenLedger.ts index 4bf5eaca1..04d0fbb2b 100644 --- a/crates/agent-gui/src/lib/chat/compaction/tokenLedger.ts +++ b/crates/agent-gui/src/lib/chat/compaction/tokenLedger.ts @@ -1,64 +1,29 @@ import type { Context, Message, Usage } from "@earendil-works/pi-ai"; +import { + estimateTextTokens, + estimateTextTokenUnits, + MESSAGE_ENVELOPE_TOKENS, + stringifiedTokenUnits, +} from "@liveagent/ui/lib/chat/contextUsage"; import { isCompactionAssistantMessage } from "../conversation/conversationState"; +import { readMessageContextUsage, writeAssistantContextUsage } from "./contextUsageMetadata"; -const CHARS_PER_TOKEN = 4; -// CJK 文字的 token 密度远高于西文:主流 tokenizer(o200k/cl100k/Claude)大约 -// 每 1.4~1.7 个汉字 1 token。按 chars/4 估会低估约 2.5~3 倍,导致压缩触发 -// 严重偏晚甚至撞上下文上限。取 0.7 token/字作为偏保守(宁早勿晚)的估计。 -const CJK_TOKENS_PER_CHAR = 0.7; -// 逐消息估算只统计正文字符,补一个小常量近似 JSON 包裹(role/键名/引号)的开销。 -const MESSAGE_ENVELOPE_TOKENS = 8; +// CJK 感知的文本估算、消息包裹常量与非文本值序列化估算全部取自共享层 +//(用量环的检查点估值与 WebUI 倒扫复用同一口径,调参只改共享层); +// 这里 re-export 文本估算保持既有调用方与测试不动。 +export { estimateTextTokens, estimateTextTokenUnits }; + +// liveAgentContextUsage 印章的不变量:totalTokens 只记录 usage 派生的权威值 +//(fixedTokens 随印章携带,供跨端 rebase 补偿 system/tools 开销变化),绝不写 +// 估算——印章随会话持久化且读取侧优先于 usage,一旦写入估算便永久遮蔽后到的 +// 真实读数,且没有任何纠正路径。 // 消息在本代码库中是不可变值对象(状态变更只新建数组),因此估算结果可跨 // state/segment/临时 state 按对象身份缓存,热路径不再重复序列化。 const messageTokenCache = new WeakMap(); const toolsTokenCache = new WeakMap(); -// CJK 统一表意文字(含扩展 A)、假名、谚文、兼容表意/形式与全角标点。 -// 这些区段全部落在 BMP,按 UTF-16 code unit 判断即可;增补平面字符 -// (emoji 等)按两个西文字符计入 chars/4 路径。 -function isCjkCodeUnit(code: number): boolean { - return ( - (code >= 0x2e80 && code <= 0x9fff) || - (code >= 0xac00 && code <= 0xd7af) || - (code >= 0x1100 && code <= 0x11ff) || - (code >= 0xf900 && code <= 0xfaff) || - (code >= 0xfe30 && code <= 0xfe4f) || - (code >= 0xff00 && code <= 0xffef) - ); -} - -/** - * 文本的分数 token 估算(不 trim、不取整)。按字符类别累加:CJK 字符按 - * CJK_TOKENS_PER_CHAR,其余按 1/CHARS_PER_TOKEN。可加性成立:对任意切分, - * 分段估算之和恒等于整体估算,因此流式增量可按 delta 累加。 - */ -export function estimateTextTokenUnits(text: string): number { - let cjkChars = 0; - for (let index = 0; index < text.length; index += 1) { - if (isCjkCodeUnit(text.charCodeAt(index))) cjkChars += 1; - } - return (text.length - cjkChars) / CHARS_PER_TOKEN + cjkChars * CJK_TOKENS_PER_CHAR; -} - -export function estimateTextTokens(text: string): number { - const normalized = text.trim(); - if (!normalized) return 0; - return Math.ceil(estimateTextTokenUnits(normalized)); -} - -function stringifiedTokenUnits(value: unknown): number { - if (typeof value === "string") return estimateTextTokenUnits(value); - if (value == null) return 0; - try { - const serialized = JSON.stringify(value); - return serialized ? estimateTextTokenUnits(serialized) : 0; - } catch { - return estimateTextTokenUnits(String(value)); - } -} - function estimateMessageTokenUnits(message: Message): number { let units = 0; if (message.role === "assistant") { @@ -125,6 +90,12 @@ export function estimateToolsTokens(tools: Context["tools"]): number { return tokens; } +export function deriveContextTokens(context: Context, options?: { fixedTokens?: number }): number { + const ledger = new TokenLedger(); + ledger.rebase(context, options); + return ledger.total(); +} + export function getUsageTotalTokens(usage: Usage | undefined): number | undefined { if (!usage) return undefined; @@ -148,46 +119,73 @@ export function getMessageObservedTokens(message: Message): number | undefined { // (布尔化避免类型谓词在 else 分支把 AssistantMessage 收窄成 never。) const isCheckpoint: boolean = isCompactionAssistantMessage(message); if (isCheckpoint) return undefined; - return getUsageTotalTokens(message.usage); + return readMessageContextUsage(message)?.totalTokens ?? getUsageTotalTokens(message.usage); } export type TokenLedgerSnapshot = { fixedTokens: number; observedTokens: number; trailingTokens: number; + // 仅在无 usage 锚点时维护(total() 也只在该情形读取);有锚点时恒为 fixedTokens。 + estimatedTotalTokens: number; hasObservedUsage: boolean; + hasFixedTokenAnchor: boolean; totalTokens: number; }; /** * 每会话上下文规模账本:observed(最近一次真实 usage,已含 system/tools/全部历史) - * + trailing(其后消息的估算增量)。无 usage 锚点时退回 fixed(system+tools 估算) - * + trailing。所有读数 O(1),重建仅在每次请求开始时 O(n) 一次。 + * + trailing(其后消息的估算增量)。有 usage 锚点时读数恒为 observed + trailing—— + * 估算口径有意偏保守(高估),绝不允许覆盖真实读数;仅在完全没有 usage 锚点时 + * 退回 fixed(system+tools 估算)+ 逐消息估算。所有读数 O(1),重建仅在每次请求 + * 开始时执行一次。 */ export class TokenLedger { private fixedTokens = 0; private observedTokens = 0; private trailingTokens = 0; + private estimatedTotalTokens = 0; private hasObservedUsage = false; + private hasFixedTokenAnchor = false; - rebase(context: Context): void { - this.fixedTokens = + rebase(context: Context, options?: { fixedTokens?: number }): void { + const estimatedFixedTokens = estimateTextTokens(context.systemPrompt ?? "") + estimateToolsTokens(context.tools); + this.fixedTokens = + typeof options?.fixedTokens === "number" && + Number.isFinite(options.fixedTokens) && + options.fixedTokens >= 0 + ? Math.floor(options.fixedTokens) + : estimatedFixedTokens; this.observedTokens = 0; this.trailingTokens = 0; + this.estimatedTotalTokens = this.fixedTokens; this.hasObservedUsage = false; + this.hasFixedTokenAnchor = false; const messages = context.messages; let anchorIndex = -1; for (let index = messages.length - 1; index >= 0; index -= 1) { - const observed = getMessageObservedTokens(messages[index]); + const message = messages[index]; + const observed = getMessageObservedTokens(message); if (typeof observed === "number") { - this.observedTokens = observed; + const anchored = readMessageContextUsage(message); + this.observedTokens = anchored + ? Math.max(0, observed + this.fixedTokens - anchored.fixedTokens) + : observed; this.hasObservedUsage = true; + this.hasFixedTokenAnchor = anchored !== undefined; anchorIndex = index; break; } } + // estimatedTotalTokens 仅在无锚点时维护:有锚点时 total() 不读它,跳过 + // 全量估算循环让重建成本随锚点后的消息数而非全历史增长。 + if (anchorIndex < 0) { + for (const message of messages) { + this.estimatedTotalTokens += estimateMessageTokens(message); + } + } for (let index = anchorIndex + 1; index < messages.length; index += 1) { this.trailingTokens += estimateMessageTokens(messages[index]); } @@ -195,11 +193,27 @@ export class TokenLedger { addMessages(messages: readonly Message[]): void { for (const message of messages) { + if (!this.hasObservedUsage) { + this.estimatedTotalTokens += estimateMessageTokens(message); + } const observed = getMessageObservedTokens(message); if (typeof observed === "number") { + if ( + message.role === "assistant" && + !isCompactionAssistantMessage(message) && + readMessageContextUsage(message) === undefined + ) { + // 印章只盖 usage 派生的权威值(见文件头部不变量);无 usage 的 + // assistant 消息不盖章,走下方 trailing 估算路径。 + writeAssistantContextUsage(message, { + totalTokens: observed, + fixedTokens: this.fixedTokens, + }); + } // 新 usage 已覆盖它之前的全部上下文,trailing 归零重新累计。 this.observedTokens = observed; this.hasObservedUsage = true; + this.hasFixedTokenAnchor = readMessageContextUsage(message) !== undefined; this.trailingTokens = 0; continue; } @@ -208,8 +222,11 @@ export class TokenLedger { } total(): number { - const base = this.hasObservedUsage ? this.observedTokens : this.fixedTokens; - return base + this.trailingTokens; + // 有 usage 锚点时恒信 observed + trailing:估算(尤其 base64 图片按序列化 + // 字符数、CJK 按 0.7 tok/char)有意高估,与真实读数取 max 会让环读数与 + // 自动压缩被估算劫持。估算只在完全没有 usage 锚点时兜底。 + if (!this.hasObservedUsage) return this.estimatedTotalTokens; + return this.observedTokens + this.trailingTokens; } /** @@ -226,7 +243,9 @@ export class TokenLedger { fixedTokens: this.fixedTokens, observedTokens: this.observedTokens, trailingTokens: this.trailingTokens, + estimatedTotalTokens: this.estimatedTotalTokens, hasObservedUsage: this.hasObservedUsage, + hasFixedTokenAnchor: this.hasFixedTokenAnchor, totalTokens: this.total(), }; } diff --git a/crates/agent-gui/src/lib/chat/compaction/types.ts b/crates/agent-gui/src/lib/chat/compaction/types.ts index 87fed85cc..1f2577e72 100644 --- a/crates/agent-gui/src/lib/chat/compaction/types.ts +++ b/crates/agent-gui/src/lib/chat/compaction/types.ts @@ -1,6 +1,6 @@ export type { ProviderRuntimeConfig } from "../../providers/runtime/types"; -export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool"; +export type CompactionTrigger = "pre-send" | "mid-stream" | "post-tool" | "manual"; // optimization = 发送前的从容压缩(阈值更宽),protection = 运行中的保护性压缩(阈值更紧)。 export type CompactionIntent = "optimization" | "protection"; @@ -31,6 +31,7 @@ export type CompactionDecisionReason = | "no-active-messages" | "in-flight" | "below-threshold" + | "below-manual-threshold" | "cooldown" | "threshold-exceeded"; diff --git a/crates/agent-gui/src/lib/chat/conversation/conversationState.ts b/crates/agent-gui/src/lib/chat/conversation/conversationState.ts index 24e2718db..17dfb9cef 100644 --- a/crates/agent-gui/src/lib/chat/conversation/conversationState.ts +++ b/crates/agent-gui/src/lib/chat/conversation/conversationState.ts @@ -47,6 +47,7 @@ export type StoredSummaryMessage = { sourceMessageCount: number; estimatedInputTokens?: number; outputTokens?: number; + contextTokensAfter?: number; summarizer?: { inputTokens?: number; outputTokens?: number; @@ -110,6 +111,9 @@ export type RenderSummaryCard = { model: string; promptVersion?: string; }; + // 压缩落定时的权威上下文占用快照(stats.contextTokensAfter);用量环 + // 扫描优先读它,避免退回摘要正文估算(与 WebUI checkpoint 行同口径)。 + contextUsageTokens?: number; timestamp: number; collapsed: boolean; }; @@ -646,6 +650,7 @@ function buildTimelineItemsForSlice( const items: RenderTimelineItem[] = []; if (options?.includeSummary !== false && slice.startMessageIndex === 0 && slice.summary) { + const contextTokensAfter = slice.summary.summaryMeta.stats?.contextTokensAfter; items.push({ kind: "summary", key: `summary-${slice.segmentId}-${slice.summary.id}`, @@ -655,6 +660,11 @@ function buildTimelineItemsForSlice( coveredMessageCount: slice.summary.summaryMeta.coveredMessageCount, coversThroughMessageId: slice.summary.summaryMeta.coversThroughMessageId, generatedBy: slice.summary.summaryMeta.generatedBy, + ...(typeof contextTokensAfter === "number" && + Number.isFinite(contextTokensAfter) && + contextTokensAfter > 0 + ? { contextUsageTokens: Math.floor(contextTokensAfter) } + : {}), timestamp: slice.summary.timestamp, collapsed: true, }); @@ -1177,6 +1187,13 @@ function shiftUiRounds(rounds: UiRound[], offset: number): UiRound[] { }); } +function markRenderOnlyRounds(rounds: UiRound[], offset: number): UiRound[] { + return shiftUiRounds(rounds, offset).map((round) => ({ + ...round, + meta: { ...(round.meta ?? {}), contextRelevant: false }, + })); +} + function getLastRoundNumber(rounds: UiRound[]) { return rounds.reduce((max, round) => Math.max(max, round.round), 0); } @@ -1210,7 +1227,7 @@ export function appendRenderOnlyMessagesToConversation( const roundOffset = getLastRoundNumber(lastItem.rounds); transcriptItems[lastIndex] = { ...lastItem, - rounds: [...lastItem.rounds, ...shiftUiRounds(sourceRounds, roundOffset)], + rounds: [...lastItem.rounds, ...markRenderOnlyRounds(sourceRounds, roundOffset)], timestamp, }; continue; @@ -1220,7 +1237,7 @@ export function appendRenderOnlyMessagesToConversation( kind: "assistant", key: `render-only-${getActiveSegment(state)?.segmentId ?? state.activeSegmentIndex}-${transcriptItems.length}-${timestamp}`, segmentIndex: activeSegmentIndex, - rounds: sourceRounds, + rounds: markRenderOnlyRounds(sourceRounds, 0), timestamp, isFromCompactedSegment: false, }); diff --git a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts index ecc2572cb..e9f8fcedd 100644 --- a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts +++ b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts @@ -36,6 +36,8 @@ function buildGatewayMessageRefPayload(ref: HistoryMessageRef): Record | void; +export type ManualCompactionTerminalStatus = "compacted" | "failed" | "busy" | "skipped"; + type GatewayBridgeEventControllerParams = { conversationId: string; requestId: string; @@ -64,7 +66,12 @@ export type GatewayBridgeEventController = { queueTitle: (nextTitle: string, allowAfterClose?: boolean) => void; queueToolStatus: (status: string | null, isCompaction?: boolean) => void; queueRetryAttempts: (attempts: readonly RetryAttemptRecord[]) => void; - queueCheckpoint: (state: ConversationViewState) => void; + queueCheckpoint: (state: ConversationViewState, contextUsageTokens?: number) => void; + queueManualCompactionResult: ( + operationId: string, + status: ManualCompactionTerminalStatus, + message?: string, + ) => void; emitError: (message: string, conversationIdOverride?: string) => void; close: () => Promise; hasForwardedText: () => boolean; @@ -176,7 +183,7 @@ export function createGatewayBridgeEventController( }, queueToolStatus, queueRetryAttempts, - queueCheckpoint(state: ConversationViewState) { + queueCheckpoint(state: ConversationViewState, contextUsageTokens?: number) { const activeSegment = state.segments[state.activeSegmentIndex]; const summary = activeSegment?.summary; if (!summary?.content.trim()) return; @@ -199,9 +206,29 @@ export function createGatewayBridgeEventController( model: summary.summaryMeta.generatedBy.model, promptVersion: summary.summaryMeta.generatedBy.promptVersion, }, + ...(typeof contextUsageTokens === "number" && contextUsageTokens > 0 + ? { contextUsageTokens: Math.floor(contextUsageTokens) } + : {}), }, }); }, + queueManualCompactionResult(operationId, status, message) { + // 终态结果事件经可靠 ingress 送达;queueEvent 可能返回投递 Promise, + // 丢弃它会让 ingress 失败无人捕获。对 Promise 显式 catch,同步返回值 + // (enabled=false 或同步 sink)自然跳过。 + const sendResult = queueEvent({ + type: "manual_compaction_result", + operationId: operationId.trim(), + status, + ...(message?.trim() ? { message: message.trim() } : {}), + conversation_id: params.conversationId, + }); + if (sendResult && typeof (sendResult as Promise).then === "function") { + (sendResult as Promise).catch((error) => { + console.warn("manual compaction result event failed", error); + }); + } + }, emitError(message: string, conversationIdOverride?: string) { queueEvent({ type: "error", diff --git a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts index 2990f1991..c6ac0b03e 100644 --- a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts +++ b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts @@ -17,6 +17,7 @@ import { } from "../../providers/nativeWebSearch"; import { isSubagentCardToolCall } from "../../subagents/card"; import { GLOBAL_BASH_MAX_TIMEOUT_MS, MIN_BASH_TIMEOUT_MS } from "../../tools/bashTimeoutPolicy"; +import { readMessageContextUsage } from "../compaction/contextUsageMetadata"; import { enrichHostedSearchContentWithText, type HostedSearchBlock, @@ -72,6 +73,8 @@ export type UiRound = { stopReason?: string; usage?: Usage; usageTotalTokens?: number; + contextUsageTokens?: number; + contextRelevant?: boolean; }; }; @@ -1222,6 +1225,7 @@ export function buildUiMessages(messages: Message[], indexOffset = 0): UiMessage if (messages[i].role === "assistant") { roundNum += 1; const assistant = messages[i] as AssistantMessage; + const contextUsage = readMessageContextUsage(assistant); lastAssistantTimestamp = assistant.timestamp ?? lastAssistantTimestamp; const toolResults: ToolResultMessage[] = []; @@ -1253,6 +1257,7 @@ export function buildUiMessages(messages: Message[], indexOffset = 0): UiMessage stopReason: String(assistant.stopReason ?? ""), usage: assistant.usage as Usage | undefined, usageTotalTokens: assistant.usage?.totalTokens, + contextUsageTokens: contextUsage?.totalTokens, }, }); } else { diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 2bc69452a..237ac2c72 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -26,6 +26,10 @@ import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/ import { useLocale } from "@liveagent/ui/i18n/index"; import { getAutomationState, useAutomation } from "@liveagent/ui/lib/automation/index"; import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; +import { + buildContextUsageScanItems, + deriveContextUsageTokens, +} from "@liveagent/ui/lib/chat/contextUsage"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress"; import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; @@ -41,6 +45,7 @@ import { } from "@liveagent/ui/lib/sidebar/selectors"; import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; +import { buildSkillsSystemPrompt, type SkillSummary } from "@liveagent/ui/lib/skills/index"; import { terminalSessionBelongsToProject } from "@liveagent/ui/lib/terminal/sessionStore"; import type { LocalTunnelClient } from "@liveagent/ui/lib/tunnels/constants"; import { listen } from "@tauri-apps/api/event"; @@ -77,6 +82,7 @@ import { getFirstUserMessageText, } from "../lib/chat/page/chatPageHelpers"; import { tauriGitClient } from "../lib/git/tauriGitClient"; +import { buildMemoryOverviewSection } from "../lib/memory/prompts/injection"; import { type AppSettings, getRightDockFileTreeState, @@ -152,6 +158,11 @@ import { import { useChatTurnQueue } from "./chat/queue/useChatTurnQueue"; import { syncMovedConversationRuntimeWorkdir } from "./chat/runtime/chatPageRuntime"; import { useChatModelSelection } from "./chat/runtime/useChatModelSelection"; +import { + type ManualCompactionRequest, + type ManualCompactionResult, + useManualCompaction, +} from "./chat/runtime/useManualCompaction"; import { useSendChatTurn } from "./chat/runtime/useSendChatTurn"; import { ChatSidebarContainer } from "./chat/sidebar/ChatSidebarContainer"; import { useProjectTerminals } from "./chat/workspace/useProjectTerminals"; @@ -428,6 +439,10 @@ export function ChatPage(props: ChatPageProps) { [], ); const sendActionRef = useRef(async () => false); + // WebUI 经 chat_queue compact_now 中继的手动压缩入口(useChatTurnQueue 消费)。 + const manualCompactActionRef = useRef< + (request?: ManualCompactionRequest) => Promise + >(async () => ({ status: "skipped" })); const ensureGatewayBridgeConversationReadyRef = useRef< (id: string, options?: EnsureGatewayBridgeConversationReadyOptions) => Promise >(async (id) => id.trim()); @@ -469,6 +484,60 @@ export function ChatPage(props: ChatPageProps) { registerGatewayRunMirror, finishGatewayRunMirror, } = useGatewayRunMirrorCoordinator(); + + // 用量环读数:与 WebUI 同一把共享扫描器(deriveContextUsageTokens), + // 历史项 + 流式实时轮次(live store 每帧批量提交)联合倒扫。经订阅源 + // 直达环组件,流式读数逐帧更新而不回流 ChatPage。 + const contextUsageRingRunning = isSending || compactionStatus.phase === "running"; + const contextUsageTokensSource = useMemo(() => { + let cache: { + rounds: unknown; + draft: string; + runtimeValue: number | undefined; + value: number | undefined; + } | null = null; + return { + subscribe: liveTranscriptStore.subscribe, + getContextUsageTokens: () => { + const live = liveTranscriptStore.getSnapshot(); + // 与 TranscriptList 的 live tail 同一门槛:只有当前会话在跑(发送或 + // 压缩中)才把流式尾部计入(后台会话的 live store 内容不属于本会话)。 + const includeLive = contextUsageRingRunning && !live.isSettled; + const rounds = includeLive ? live.liveRounds : null; + const draft = includeLive ? live.draftAssistantText : ""; + const runtimeValue = getCompactionController(currentConversationId).contextUsageTokens; + if ( + cache && + cache.rounds === rounds && + cache.draft === draft && + cache.runtimeValue === runtimeValue + ) { + return cache.value; + } + // 优先级:运行中(发送/压缩)转录尾部滞后于账本,账本读数优先;空闲时 + // 转录含权威锚点(edit-resend 截断历史后账本仍冻结在压缩前读数),转录 + // 扫描才准。惰性求值:命中账本优先项即跳过全量转录扫描(流式期每帧对 + // 大工具结果 JSON.stringify 后丢弃的开销)。 + let value: number | undefined; + if (contextUsageRingRunning && runtimeValue !== undefined) { + value = runtimeValue; + } else { + const transcriptValue = deriveContextUsageTokens( + buildContextUsageScanItems(transcriptItems, includeLive ? live : null), + ); + value = transcriptValue ?? runtimeValue; + } + cache = { rounds, draft, runtimeValue, value }; + return value; + }, + }; + }, [ + contextUsageRingRunning, + currentConversationId, + getCompactionController, + liveTranscriptStore, + transcriptItems, + ]); const { currentConversationIdRef, conversationRuntimeCacheRef, @@ -483,6 +552,7 @@ export function ChatPage(props: ChatPageProps) { getConversationStopRequestVersion, isConversationStopRequested, consumeConversationStop, + setConversationRunningState, setConversationStopHandler, clearConversationStopHandler, requestActiveConversationStop, @@ -994,6 +1064,7 @@ export function ChatPage(props: ChatPageProps) { clearCachedComposerDraft, displayedConversationWorkdir, sendActionRef, + manualCompactActionRef, }); // Queue snapshots publish on queue mutation only; after a gateway @@ -1482,6 +1553,73 @@ export function ChatPage(props: ChatPageProps) { sendActionRef.current = send; stopSendingActionRef.current = stopSending; + // 手动压缩的同源提示词构建:当前会话据其工作区解析 skills/memory 提示词, + // 与发送链路的 buildPreparedContext 同源(activeAgentPrompt 单独直传)。手动 + // 压缩无触发消息,skills 的 explicit 提及为空。跨会话中继的后台会话在此层拿 + // 不到工作区上下文,返回空提示词(当前会话必须同源,后台保持现状)。 + const resolveManualCompactionPromptInputs = useCallback( + async (input: { isCurrentConversation: boolean; workdir?: string }) => { + if (!input.isCurrentConversation) { + return { skillsPrompt: "", memoryPrompt: "" }; + } + const promptWorkdir = input.workdir?.trim() ?? ""; + const resources = resolveWorkspaceResources(settings, promptWorkdir); + let skillsPrompt = ""; + if (resources.skillsEnabled && isAgentMode && resources.skillNames.length > 0) { + const byName = new Map(availableSkills.map((skill) => [skill.name, skill])); + const selectedSkills = resources.skillNames + .map((name) => byName.get(name)) + .filter((skill): skill is SkillSummary => Boolean(skill)); + if (selectedSkills.length > 0) { + skillsPrompt = buildSkillsSystemPrompt({ + rootDir: skillsRootDir, + selected: selectedSkills, + }); + } + } + let memoryPrompt = ""; + if (promptWorkdir) { + try { + memoryPrompt = await buildMemoryOverviewSection(promptWorkdir); + } catch (error) { + console.warn("Failed to build manual compaction memory prompt", error); + memoryPrompt = ""; + } + } + return { skillsPrompt, memoryPrompt }; + }, + [availableSkills, isAgentMode, settings, skillsRootDir], + ); + + const handleManualCompact = useManualCompaction({ + settings, + t, + currentConversationIdRef, + isConversationRunning, + setConversationRunningState, + setConversationAbortController, + setConversationStopHandler, + clearConversationStopHandler, + consumeConversationStop, + buildRuntimeEntryFromVisibleState, + conversationRuntimeCacheRef, + ensureConversationReady: ensureGatewayBridgeConversationReady, + getCompactionController, + getConversationLiveTranscriptStore, + updateConversationRuntimeEntry, + resetLiveTranscript, + updateToolStatus, + queueGatewayBridgeEventForRequest, + flushGatewayBridgeEventsForRequest, + registerGatewayRunMirror, + finishGatewayRunMirror, + persistConversation, + setErrorMessage, + activeAgentPrompt, + resolveManualCompactionPromptInputs, + }); + manualCompactActionRef.current = handleManualCompact; + const handleOpenSidebar = useCallback(() => { setSidebarOpen(true); }, []); @@ -2063,6 +2201,10 @@ export function ChatPage(props: ChatPageProps) { chatRuntimeControls={chatRuntimeControlsForCurrentProvider} reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} + contextUsageTokensSource={contextUsageTokensSource} + contextWindow={currentModelContextWindow} + onManualCompactConfirm={handleManualCompact} + manualCompactBlocked={isCompactionRunning} gitClient={tauriGitClient} workspaceActivityClient={tauriWorkspaceActivityClient} onSend={handleSend} diff --git a/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts b/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts index 3a12f98fa..615637f08 100644 --- a/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts +++ b/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts @@ -25,6 +25,8 @@ type GatewayAssistantMeta = { stopReason?: string; usage?: Usage; usageTotalTokens?: number; + contextUsageTokens?: number; + contextRelevant?: boolean; }; export type GatewayRuntimeSnapshotEntry = diff --git a/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts b/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts index 468a45c62..d702e0338 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useChatPageRuntimeStore.ts @@ -287,12 +287,8 @@ export function useChatPageRuntimeStore(params: UseChatPageRuntimeStoreParams) { [], ); - const setConversationSendingState = useCallback( + const setConversationRunningState = useCallback( (conversationId: string, value: boolean) => { - updateConversationRuntimeEntry(conversationId, (prev) => ({ - ...prev, - isSending: value, - })); const key = conversationId.trim(); if (!key) return; if (value) { @@ -313,7 +309,18 @@ export function useChatPageRuntimeStore(params: UseChatPageRuntimeStoreParams) { return next; }); }, - [setRunningConversationIds, updateConversationRuntimeEntry], + [setRunningConversationIds], + ); + + const setConversationSendingState = useCallback( + (conversationId: string, value: boolean) => { + updateConversationRuntimeEntry(conversationId, (prev) => ({ + ...prev, + isSending: value, + })); + setConversationRunningState(conversationId, value); + }, + [setConversationRunningState, updateConversationRuntimeEntry], ); useEffect(() => { @@ -376,6 +383,7 @@ export function useChatPageRuntimeStore(params: UseChatPageRuntimeStoreParams) { setConversationStopHandler, clearConversationStopHandler, requestActiveConversationStop, + setConversationRunningState, setConversationSendingState, }; } diff --git a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts index 76c022209..09603e039 100644 --- a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts @@ -25,6 +25,10 @@ import { normalizeGatewayWorkdir, } from "../gateway/gatewayBridgeTypes"; import type { ConversationRuntimeEntry } from "../runtime/chatPageRuntime"; +import type { + ManualCompactionRequest, + ManualCompactionResult, +} from "../runtime/useManualCompaction"; import { appendQueuedChatTurn, buildQueuedChatTurnPreview, @@ -74,6 +78,10 @@ type UseChatTurnQueueParams = { clearCachedComposerDraft: (conversationId?: string) => void; displayedConversationWorkdir: string; sendActionRef: MutableRefObject; + /** WebUI compact_now 中继:调 ChatPage 的手动压缩入口(与本地用量环同一代码)。 */ + manualCompactActionRef: MutableRefObject< + (request?: ManualCompactionRequest) => Promise + >; }; /** @@ -109,6 +117,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { clearCachedComposerDraft, displayedConversationWorkdir, sendActionRef, + manualCompactActionRef, } = params; const [queuedChatTurns, setQueuedChatTurns] = useState([]); @@ -875,6 +884,76 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { return; } + // WebUI 用量环触发的手动压缩:按目标 conversationId 装载独立 runtime, + // 不要求桌面当前正显示该会话;运行中与单飞校验仍按目标会话隔离。 + // 回包时序:不再"受理即回包"。手动压缩探针(无副作用)通过、真正开始 + // 压缩时经 onAccepted 同步回 accepted:true;探针拒绝/前置抛错则据返回值 + // 同步回 accepted:false + message(WebUI 已在 !accepted 时展示 message)。 + // 只有真正开始压缩才会后续经 operationId 关联终态事件。Rust relay 30s + // 超时 >> 探针耗时(纯 token 计数,无 LLM 调用),安全。本 effect 闭包是 + // []-dep,校验只读恒新的 ref;细校验由 manualCompactActionRef 指向的最新 + // 闭包自行复核。 + if (action === "compact_now") { + if (isConversationRunning(conversationId)) { + fail("conversation is running", "busy"); + return; + } + if ( + conversationRuntimeCacheRef.current.get(conversationId)?.compactionStatus.phase === + "running" + ) { + fail("compaction already in progress", "compacting"); + return; + } + // operationId 严格化:缺失或非空字符串解析失败直接拒绝。回退到 requestId + // 会产生 WebUI 从未登记的 operationId,终态永不匹配、挂满 5 分钟超时。 + let operationId = ""; + if (request.requestJson?.trim()) { + try { + const payload = JSON.parse(request.requestJson) as { operationId?: unknown }; + if (typeof payload.operationId === "string" && payload.operationId.trim()) { + operationId = payload.operationId.trim(); + } + } catch { + fail("invalid manual compaction payload", "invalid_payload"); + return; + } + } + if (!operationId) { + fail("manual compaction requires operationId", "invalid_payload"); + return; + } + const codeFor = (status: ManualCompactionResult["status"]) => + status === "busy" ? "busy" : status === "skipped" ? "skipped" : "failed"; + let responded = false; + const respondAccepted = () => { + if (responded) return; + responded = true; + respond(requestId, { accepted: true }); + }; + void manualCompactActionRef + .current({ + conversationId, + operationId, + onAccepted: respondAccepted, + }) + .then((result) => { + // 已受理即真正开始压缩,终态改经 operationId 事件;未受理说明探针 + // 拒绝,此处据返回值同步回包。 + if (responded) return; + responded = true; + fail(result.message || "manual compaction declined", codeFor(result.status)); + }) + .catch((error) => { + if (!responded) { + responded = true; + fail(String(error), "failed"); + } + console.warn("manual compaction relayed from WebUI failed", error); + }); + return; + } + const item = queuedChatTurnsRef.current.find( (candidate) => candidate.id === itemId && candidate.conversationId === conversationId, ); diff --git a/crates/agent-gui/src/pages/chat/runtime/useManualCompaction.ts b/crates/agent-gui/src/pages/chat/runtime/useManualCompaction.ts new file mode 100644 index 000000000..6e46e5d5d --- /dev/null +++ b/crates/agent-gui/src/pages/chat/runtime/useManualCompaction.ts @@ -0,0 +1,506 @@ +import { deriveContextUsageTokens } from "@liveagent/ui/lib/chat/contextUsage"; +import { invoke } from "@tauri-apps/api/core"; +import type { MutableRefObject } from "react"; +import { useCallback } from "react"; +import { readMessageContextUsage } from "../../../lib/chat/compaction/contextUsageMetadata"; +import type { + CompactionController, + CompactionSinks, + ManualCompactionOutcome, + ManualContextUsageSnapshot, +} from "../../../lib/chat/compaction/controller"; +import type { CompactionDecisionReason } from "../../../lib/chat/compaction/types"; +import { getActiveSegment } from "../../../lib/chat/conversation/conversationState"; +import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTranscriptStore"; +import { createGatewayBridgeEventController } from "../../../lib/chat/conversation/run/gatewayBridgeEvents"; +import { createTurnCancellation } from "../../../lib/chat/conversation/turnCancellation"; +import { createProviderRuntimeConfig } from "../../../lib/providers/llm"; +import type { AppSettings } from "../../../lib/settings"; +import { createLocalGatewayChatRunId } from "../gateway/gatewayRuntimeStatusModel"; +import type { + FinishGatewayRunMirrorInput, + RegisterGatewayRunMirrorInput, +} from "../gateway/useGatewayRunMirrorCoordinator"; +import type { PersistConversationParams } from "../history/useConversationHistoryActions"; +import type { ConversationRuntimeEntry } from "./chatPageRuntime"; +import { + buildPreparedContext as buildPreparedConversationContext, + buildResumeContext as buildResumeConversationContext, +} from "./conversationContextBuilders"; +import { resolveEffectiveChatModelSelection } from "./modelSelection"; + +export type ManualCompactionResult = { + status: "compacted" | "failed" | "busy" | "skipped"; + message?: string; +}; + +export type ManualCompactionRequest = { + conversationId?: string; + operationId?: string; + // 中继层受理回调:探针通过、真正开始压缩时同步调用一次。被拒绝的压缩 + // 从不触发它,中继层据返回值同步回包(accepted:false + message)。 + onAccepted?: () => void; +}; + +// 手动压缩的读数快照:优先用控制器账本,缺失才退到转录扫描与最近一条 +// 带 fixedTokens 的消息元数据。 +function resolveManualContextUsage( + controller: CompactionController, + runtimeEntry: ConversationRuntimeEntry, +): ManualContextUsageSnapshot { + const runtimeSnapshot = controller.contextUsageSnapshot; + const messages = getActiveSegment(runtimeEntry.state)?.messages ?? []; + let fixedTokens: number | undefined; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const usage = readMessageContextUsage(messages[index]); + if (usage) { + fixedTokens = usage.fixedTokens; + break; + } + } + return { + totalTokens: + runtimeSnapshot?.totalTokens ?? deriveContextUsageTokens(runtimeEntry.state.transcript.items), + fixedTokens: runtimeSnapshot?.fixedTokens ?? fixedTokens, + }; +} + +type ConversationStopHandler = (options: { force: boolean; requestVersion: number }) => void; + +/** + * 手动压缩的装配单点:把发送链路同源的 sinks / providerConfig / gateway bridge + * 组装为一次 CompactionController.compactManually 调用。仅空闲时执行;压缩进行 + * 状态与检查点经既有 bridge 通道镜像到 WebUI。 + * + * 不变量(run 生命周期只在真正压缩时成立):桥接事件走可靠 ingress,网关会 + * 为任意 run 的首个 delta 建立真实 run activity——因此任何 run 痕迹都必须推迟 + * 到探针通过之后。前置校验(running/runtime/模型/compactionStatus)全程零 run + * 痕迹;gateway_chat_mark_local_started、registerGatewayRunMirror 只在 + * compactManually 的 onProceed 回调里发生(onProceed=true 才置 proceeded); + * finally 的 queueManualCompactionResult / finishGatewayRunMirror 只在 proceeded + * 时执行。被拒绝的压缩什么事件都不发,结果经返回值由中继层同步回包,避免伪造 + * 空 run(WebUI 折叠转录、composer 忙碌、空 run 永久重放)。 + * + * 停止语义:压缩期间注册与发送链路同款的停止处理器 + abort controller。用户 + * 停止时经 cancellation.userStop.abort() 中止 compactManually(返回 aborted), + * 并在 finally 消费 stop intent(否则吞掉下一条消息 / 二次 force 与队列 drain + * 并发)。 + */ +export function useManualCompaction(params: { + settings: AppSettings; + t: (key: string) => string; + currentConversationIdRef: MutableRefObject; + isConversationRunning: (conversationId: string) => boolean; + setConversationRunningState: (conversationId: string, value: boolean) => void; + setConversationAbortController: ( + conversationId: string, + controller: AbortController | null, + ) => void; + setConversationStopHandler: ( + conversationId: string, + handler: ConversationStopHandler | null, + ) => void; + clearConversationStopHandler: (conversationId: string, handler: ConversationStopHandler) => void; + consumeConversationStop: (conversationId: string, expectedVersion?: number) => boolean; + buildRuntimeEntryFromVisibleState: () => ConversationRuntimeEntry; + conversationRuntimeCacheRef: MutableRefObject>; + ensureConversationReady: (conversationId: string) => Promise; + getCompactionController: (conversationId: string) => CompactionController; + getConversationLiveTranscriptStore: (conversationId: string) => LiveTranscriptStore; + updateConversationRuntimeEntry: ( + conversationId: string, + updater: (prev: ConversationRuntimeEntry) => ConversationRuntimeEntry, + ) => void; + resetLiveTranscript: (store?: LiveTranscriptStore) => void; + updateToolStatus: (status: string | null, store?: LiveTranscriptStore) => void; + queueGatewayBridgeEventForRequest: ( + requestId: string, + event: Record, + options?: { workerId?: string }, + ) => Promise | void; + flushGatewayBridgeEventsForRequest: (requestId: string) => Promise; + registerGatewayRunMirror: (input: RegisterGatewayRunMirrorInput) => void; + finishGatewayRunMirror: (input: FinishGatewayRunMirrorInput) => Promise; + persistConversation: (params: PersistConversationParams) => Promise; + setErrorMessage: (message: string | null) => void; + activeAgentPrompt: string; + // 与发送链路同源的提示词构建:当前会话据当前工作区解析 skills/memory 提示词; + // 后台会话(跨会话中继)拿不到这些上下文,返回空串(见调用点注释)。 + resolveManualCompactionPromptInputs: (input: { + isCurrentConversation: boolean; + workdir?: string; + }) => Promise<{ skillsPrompt: string; memoryPrompt: string }>; +}) { + const { + settings, + t, + currentConversationIdRef, + isConversationRunning, + setConversationRunningState, + setConversationAbortController, + setConversationStopHandler, + clearConversationStopHandler, + consumeConversationStop, + buildRuntimeEntryFromVisibleState, + conversationRuntimeCacheRef, + ensureConversationReady, + getCompactionController, + getConversationLiveTranscriptStore, + updateConversationRuntimeEntry, + resetLiveTranscript, + updateToolStatus, + queueGatewayBridgeEventForRequest, + flushGatewayBridgeEventsForRequest, + registerGatewayRunMirror, + finishGatewayRunMirror, + persistConversation, + setErrorMessage, + activeAgentPrompt, + resolveManualCompactionPromptInputs, + } = params; + + return useCallback( + async (request?: ManualCompactionRequest): Promise => { + const conversationId = + request?.conversationId?.trim() || currentConversationIdRef.current.trim(); + if (!conversationId) { + return { status: "skipped", message: t("chat.manualCompactRejected") }; + } + + // await 后 ref 可能已切换会话,重读判定,勿冻结在闭包创建时。 + const isCurrentConversation = () => + conversationId === currentConversationIdRef.current.trim(); + const hasRemoteGatewayTarget = + settings.remote.enabled && + settings.remote.gatewayUrl.trim() !== "" && + settings.remote.token.trim() !== ""; + const bridgeRequestId = createLocalGatewayChatRunId(conversationId); + const transcriptStore = getConversationLiveTranscriptStore(conversationId); + const gatewayBridgeEvents = createGatewayBridgeEventController({ + conversationId, + requestId: bridgeRequestId, + workerId: "gui-live", + enabled: hasRemoteGatewayTarget, + sendEvent: queueGatewayBridgeEventForRequest, + flushEvents: flushGatewayBridgeEventsForRequest, + resolveErrorConversationId: () => conversationId, + }); + const resultOperationId = + request?.operationId?.trim() || createLocalGatewayChatRunId(conversationId); + + const cancellation = createTurnCancellation(); + let proceeded = false; + let runningStateClaimed = false; + let stopHandlerRegistered = false; + let stopRequestVersion: number | null = null; + // 停止处理器与发送链路 handleConversationStop 同款:记录版本号供 finally + // 消费 stop intent;abort 使 compactManually 中止(controller 返回 aborted)。 + const handleStop: ConversationStopHandler = (options) => { + stopRequestVersion = options.requestVersion; + cancellation.userStop.abort(); + }; + + const messageForSkipReason = (reason: CompactionDecisionReason): string => { + switch (reason) { + case "below-manual-threshold": + return t("chat.manualCompactBelowThreshold"); + case "no-active-messages": + return t("chat.manualCompactEmpty"); + default: + return t("chat.manualCompactUnavailable"); + } + }; + + const mapOutcome = ( + outcome: ManualCompactionOutcome, + compactionFailureMessage: string, + ): ManualCompactionResult => { + switch (outcome.status) { + case "compacted": + return { status: "compacted" }; + case "busy": + return { status: "busy", message: t("chat.manualCompactRejected") }; + case "skipped": + return { status: "skipped", message: messageForSkipReason(outcome.reason) }; + default: + // 中止(用户停止)落到 skipped + 取消文案;其余失败带失败详情。 + return outcome.aborted + ? { status: "skipped", message: t("chat.manualCompactCancelled") } + : { + status: "failed", + message: compactionFailureMessage || t("chat.manualCompactFailed"), + }; + } + }; + + let result: ManualCompactionResult = { + status: "failed", + message: t("chat.manualCompactFailed"), + }; + + const run = async (): Promise => { + if (isConversationRunning(conversationId)) { + return { status: "busy", message: t("chat.manualCompactRejected") }; + } + + // 运行时快照解析:当前会话用可见状态,但历史仍在水合时可见状态为空, + // active segment 无消息即复核一次 runtime cache(否则误报"无可压缩内容")。 + let runtimeEntry: ConversationRuntimeEntry; + if (isCurrentConversation()) { + const visibleEntry = buildRuntimeEntryFromVisibleState(); + const visibleMessages = getActiveSegment(visibleEntry.state)?.messages ?? []; + if (visibleMessages.length > 0) { + runtimeEntry = visibleEntry; + } else { + await ensureConversationReady(conversationId); + runtimeEntry = conversationRuntimeCacheRef.current.get(conversationId) ?? visibleEntry; + } + } else { + await ensureConversationReady(conversationId); + const cached = conversationRuntimeCacheRef.current.get(conversationId); + if (!cached) { + throw new Error("Conversation runtime is unavailable after history hydration"); + } + runtimeEntry = cached; + } + + // 水合可能耗时,重核一次运行态后再占用 running 标志。 + if (isConversationRunning(conversationId)) { + return { status: "busy", message: t("chat.manualCompactRejected") }; + } + setConversationRunningState(conversationId, true); + runningStateClaimed = true; + // 注册停止处理器与 abort controller(若已请求停止会立刻回调并 abort)。 + setConversationStopHandler(conversationId, handleStop); + setConversationAbortController(conversationId, cancellation.userStop); + stopHandlerRegistered = true; + + if (runtimeEntry.compactionStatus.phase === "running") { + return { status: "busy", message: t("chat.manualCompactRejected") }; + } + + let effective: ReturnType; + try { + effective = resolveEffectiveChatModelSelection({ + settings, + conversationSelectedModel: runtimeEntry.selectedModel, + }); + } catch (error) { + return { + status: "failed", + message: error instanceof Error ? error.message : String(error), + }; + } + const { provider, providerId, model, selectedModel } = effective; + const runtime = createProviderRuntimeConfig(provider, model, settings.chatRuntimeControls); + + // 与发送链路同源的检查点上下文:注入 agent/skills/memory 提示词与 tools, + // 使 checkpoint contextTokensAfter(两端环的权威锚点)计入系统提示词与 + // 工具重量,否则少算导致压缩后两端环读数偏低。 + const { skillsPrompt, memoryPrompt } = await resolveManualCompactionPromptInputs({ + isCurrentConversation: isCurrentConversation(), + workdir: runtimeEntry.workdir, + }); + + let compactionFailureMessage = ""; + const sinks: CompactionSinks = { + applyState: (state) => + updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, state })), + applyStateMidRun: (state) => { + updateConversationRuntimeEntry(conversationId, (prev) => ({ ...prev, state })); + resetLiveTranscript(transcriptStore); + }, + publishStatus: (status) => { + if (status.phase === "failed") compactionFailureMessage = status.message; + updateConversationRuntimeEntry(conversationId, (prev) => ({ + ...prev, + compactionStatus: status, + })); + }, + setBridgeToolStatus: (status, isCompaction = false) => { + gatewayBridgeEvents.queueToolStatus(status, isCompaction); + updateToolStatus(status, transcriptStore); + }, + queueCheckpoint: (state, contextUsageTokens) => + gatewayBridgeEvents.queueCheckpoint(state, contextUsageTokens), + persist: (state) => + persistConversation({ + conversationId, + sessionId: runtimeEntry.sessionId, + providerId, + model, + selectedModel, + cwd: runtimeEntry.workdir, + state, + fallbackTitle: t("chat.pendingTitle"), + createdAt: runtimeEntry.createdAt, + titlePromise: null, + }), + }; + + const compactionController = getCompactionController(conversationId); + const outcome = await compactionController.compactManually( + { + providerId, + model, + runtime, + cancellation, + sinks, + buildPreparedContext: (state, tools, options) => + buildPreparedConversationContext({ + state, + tools, + activeAgentPrompt, + skillsPrompt, + memoryPrompt, + includeAbortedMessages: options?.includeAbortedMessages, + includeUploadedFilesMetadata: options?.includeUploadedFilesMetadata, + }), + buildResumeContext: (state, resumeMessage, tools, options) => + buildResumeConversationContext({ + state, + resumeMessage, + tools, + activeAgentPrompt, + skillsPrompt, + memoryPrompt, + includeAbortedMessages: options?.includeAbortedMessages, + includeUploadedFilesMetadata: options?.includeUploadedFilesMetadata, + }), + }, + runtimeEntry.state, + resolveManualContextUsage(compactionController, runtimeEntry), + { + tools: runtimeEntry.state.meta.tools, + onProceed: () => { + proceeded = true; + if (hasRemoteGatewayTarget) { + // 与 useSendChatTurn 同款注册镜像:userMessage 取最近一条用户消息 + // (已在历史里的真实消息),transcriptStore 现成。缺 userMessage 会让 + // 网关 checkpoint 请求撞 lastError、TTL 清扫器判死未注册 mirror。 + const activeMessages = getActiveSegment(runtimeEntry.state)?.messages ?? []; + let lastUserMessage: (typeof activeMessages)[number] | undefined; + for (let index = activeMessages.length - 1; index >= 0; index -= 1) { + if (activeMessages[index]?.role === "user") { + lastUserMessage = activeMessages[index]; + break; + } + } + if (lastUserMessage) { + registerGatewayRunMirror({ + runId: bridgeRequestId, + conversationId, + workerId: "gui-live", + userMessage: lastUserMessage, + transcriptStore, + state: "running", + }); + } + // ledger 记账:2s 心跳的 active_runs 为 summarizer 静默期续命。 + void invoke("gateway_chat_mark_local_started", { + request_id: bridgeRequestId, + conversation_id: conversationId, + }).catch((error) => { + console.warn("gateway_chat_mark_local_started failed", error); + }); + } + request?.onAccepted?.(); + }, + }, + ); + + return mapOutcome(outcome, compactionFailureMessage); + }; + + try { + result = await run(); + if (result.status === "failed" && result.message && isCurrentConversation()) { + setErrorMessage(result.message); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isCurrentConversation()) { + setErrorMessage(message); + } + result = { status: "failed", message }; + return result; + } finally { + if (stopHandlerRegistered) { + clearConversationStopHandler(conversationId, handleStop); + setConversationAbortController(conversationId, null); + } + if (runningStateClaimed) { + setConversationRunningState(conversationId, false); + } + // 停止意图必须消费,否则残留会吞掉该会话的下一条消息。版本号不匹配 + // 说明其后又有新的停止请求,交由后续路径处理。 + if (stopRequestVersion !== null) { + consumeConversationStop(conversationId, stopRequestVersion); + } + // 只有真正开始压缩才有 run 痕迹需要收尾;被拒绝的压缩什么都不发。 + if (proceeded) { + try { + gatewayBridgeEvents.queueManualCompactionResult( + resultOperationId, + result.status, + result.message, + ); + } catch (error) { + console.warn("manual compaction result event failed", error); + } + try { + await gatewayBridgeEvents.close(); + } catch (error) { + console.warn("manual compaction bridge flush failed", error); + } + if (hasRemoteGatewayTarget) { + // 终态记账:compacted→completed+historyRequired(WebUI 保留检查点行经 + // 持久化历史收敛);failed→failed;skipped(含取消)走完成态收敛。 + try { + await finishGatewayRunMirror({ + runId: bridgeRequestId, + conversationId, + entriesJson: "[]", + state: result.status === "failed" ? "failed" : "completed", + errorCode: result.status === "failed" ? "manual_compaction_failed" : undefined, + errorMessage: result.status === "failed" ? result.message : undefined, + contentComplete: result.status !== "compacted", + historyRequired: result.status === "compacted", + }); + } catch (error) { + console.warn("manual compaction terminal commit failed", error); + } + } + } + } + }, + [ + activeAgentPrompt, + buildRuntimeEntryFromVisibleState, + clearConversationStopHandler, + consumeConversationStop, + conversationRuntimeCacheRef, + currentConversationIdRef, + ensureConversationReady, + finishGatewayRunMirror, + flushGatewayBridgeEventsForRequest, + getCompactionController, + getConversationLiveTranscriptStore, + isConversationRunning, + persistConversation, + queueGatewayBridgeEventForRequest, + registerGatewayRunMirror, + resetLiveTranscript, + resolveManualCompactionPromptInputs, + setConversationAbortController, + setConversationRunningState, + setConversationStopHandler, + setErrorMessage, + settings, + t, + updateConversationRuntimeEntry, + updateToolStatus, + ], + ); +} diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index 39b761a48..679f0869e 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -1205,7 +1205,8 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { compactionStatus: status, })), setBridgeToolStatus: updateGatewayBridgeToolStatus, - queueCheckpoint: (state) => gatewayBridgeEvents.queueCheckpoint(state), + queueCheckpoint: (state, contextUsageTokens) => + gatewayBridgeEvents.queueCheckpoint(state, contextUsageTokens), persist: (state) => persistConversation({ conversationId, diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index 0f7a201b8..402d59ac4 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -55,9 +55,8 @@ const transcriptMeasurementsLru = createTranscriptMeasurementsLru(); const SummaryCard = memo(function SummaryCard(props: { item: RenderSummaryCard }) { const { item } = props; - const { locale } = useLocale(); + const { t } = useLocale(); const [expanded, setExpanded] = useState(false); - const isEn = locale === "en-US"; return (
@@ -73,10 +72,13 @@ const SummaryCard = memo(function SummaryCard(props: { item: RenderSummaryCard }
- {isEn ? "Context Checkpoint" : "上下文检查点"} + {t("chat.contextCheckpoint.title")} - {item.coveredMessageCount} {isEn ? "msgs" : "条消息"} + {t("chat.contextCheckpoint.messageCount").replace( + "{count}", + String(item.coveredMessageCount), + )}
@@ -179,9 +181,12 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList }), ); + // 手动压缩空闲态只置 isCompactionRunning、不置 isSending,仍要显示「正在 + // 压缩」live tail:把它并入可见性 gate(只影响 live tail 是否显示,不改动 + // 其他 isSending 语义)。 const { rows, liveStartIndex } = useMemo( - () => rowModel.build(historyItems, { ...liveState, isSending }), - [rowModel, historyItems, liveState, isSending], + () => rowModel.build(historyItems, { ...liveState, isSending, isCompactionRunning }), + [rowModel, historyItems, liveState, isSending, isCompactionRunning], ); const rowsRef = useRef(rows); diff --git a/crates/agent-gui/src/pages/chat/transcript/rowModel.ts b/crates/agent-gui/src/pages/chat/transcript/rowModel.ts index e105da2ef..235b14ab5 100644 --- a/crates/agent-gui/src/pages/chat/transcript/rowModel.ts +++ b/crates/agent-gui/src/pages/chat/transcript/rowModel.ts @@ -114,6 +114,9 @@ export type TranscriptRowsSnapshot = { export type LiveTailInput = LiveTranscriptState & { isSending: boolean; + // 手动压缩空闲态:live store 只置 running、不置 isSending,但仍要显示「正在 + // 压缩」状态行。该标记只并入 live tail 可见性 gate,不改变其他 isSending 语义。 + isCompactionRunning?: boolean; }; function buildReplyText(rounds: (UiRound | LiveRound)[]): string { @@ -575,7 +578,8 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T historyItems: RenderTimelineItem[], live: LiveTailInput, ): TranscriptRowsSnapshot => { - const liveTailVisible = live.isSending && !live.isSettled; + const liveTailVisible = + (live.isSending || live.isCompactionRunning === true) && !live.isSettled; const isInitialBuild = !hasBuilt; hasBuilt = true; @@ -601,14 +605,29 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T settlingUnits: null, }; } else if (!liveTailVisible && activeTurn) { + // 落定交接:丢弃 activeTurn 的判据是「历史自 historyLenAtStart 起有没有 + // 新增的、尚未被认领的 assistant 孪生项」——adoptSettledTwin 的返回值正是 + // 这个判据(认领成功 ⇔ 窗口内有可领养孪生项)。不能改用「live 单元里有没有 + // 可见 block」:存在零可见 block 却有真实孪生行的 turn——被取消的 run 会 + // 持久化中止提示 assistant 项;仅输出 Task 工具的 run 其块被 + // isVisibleGroupedBlock 全部过滤。这类 turn 若被误判丢弃,孪生行永不被领养 + // → 以全新 key 重挂载(违反零 remount),persist 滞后时更会漏进下一个 run 的 + // historyLenAtStart 窗口被错位认领。 const adopted = adoptSettledTwin(historyItems, activeTurn); - if (!adopted) { + if (adopted) { + activeTurn = null; + } else if (activeTurn.lastLiveUnits.some((row) => row.unit.kind === "block")) { + // 产出过内容 ⟹ 真实回复必将持久化:孪生行尚未落库(persist 滞后)时 + // 登记 pendingSettle,待其落库后按同一 replyKey 认领(零 remount)。 pendingSettle = { replyKey: activeTurn.replyKey, historyLenAtStart: activeTurn.historyLenAtStart, }; + } else { + // 既没产出内容、历史也没有可领养孪生项(空闲手动压缩落定成检查点卡片、 + // 或产出前即被取消的 run)→ 直接清掉,避免底部留下冻结的 settling 状态行。 + activeTurn = null; } - if (adopted) activeTurn = null; } else if (!liveTailVisible && pendingSettle) { if (adoptSettledTwin(historyItems, pendingSettle)) pendingSettle = null; } diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 19898f346..80cfd0e07 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -611,7 +611,15 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP }); } - function commitAssistantRoundMeta(assistant: AssistantMessage, round: number) { + function commitAssistantRoundMeta( + assistant: AssistantMessage, + round: number, + options?: { contextRelevant?: boolean }, + ) { + const contextRelevant = options?.contextRelevant !== false; + const contextUsageTokens = contextRelevant + ? compaction.observeContextMessages([assistant]) + : undefined; gatewayBridgeEvents.queueToken("", { round, provider: assistant.provider, @@ -619,6 +627,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP api: assistant.api, stopReason: assistant.stopReason, usage: assistant.usage, + ...(contextUsageTokens ? { contextUsageTokens } : {}), + ...(contextRelevant ? {} : { contextRelevant: false }), }); batchLiveRoundsUpdate( (prev) => @@ -631,6 +641,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP stopReason: String(assistant.stopReason ?? ""), usage: assistant.usage, usageTotalTokens: assistant.usage?.totalTokens, + contextUsageTokens, + contextRelevant, }, })), transcriptStore, @@ -772,6 +784,11 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP protectionCheckChars = 0; sawToolCallInRound = false; hookLifecycle.startTurn(round); + const contextUsageTokens = compaction.contextUsageTokens; + gatewayBridgeEvents.queueToken("", { + round, + ...(contextUsageTokens ? { contextUsageTokens } : {}), + }); batchLiveRoundsUpdate( (prev) => [ ...prev, @@ -779,6 +796,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP key: `r${round}`, round, blocks: [], + meta: contextUsageTokens ? { contextUsageTokens } : undefined, runningToolCallIds: [], thinkingOpen: false, }, @@ -890,6 +908,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP }, onToolResult: (toolCall, toolResult, round) => { if (toolResult.role !== "toolResult") return; + compaction.observeContextMessages([toolResult]); discardPendingToolCallDelta(toolCall, round); if (!isSubagentCardToolCall(toolCall)) { hookLifecycle.toolResultReceived(round); @@ -1096,6 +1115,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP const extraction = await runPostTurnMemoryExtraction({ roundOffset: memoryRoundOffset, onTurnStart: (round) => { + gatewayBridgeEvents.queueToken("", { round, contextRelevant: false }); batchLiveRoundsUpdate( (prev) => [ ...prev, @@ -1103,6 +1123,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP key: `r${round}`, round, blocks: [], + meta: { contextRelevant: false }, runningToolCallIds: [], thinkingOpen: false, }, @@ -1206,7 +1227,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP transcriptStore, ); }, - onAssistantMessage: commitAssistantRoundMeta, + onAssistantMessage: (assistant, round) => + commitAssistantRoundMeta(assistant, round, { contextRelevant: false }), onToolStatus: (s) => { gatewayBridgeEvents.queueToolStatus(s, false); updateToolStatus(s, transcriptStore); diff --git a/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts index da8760149..edf0cf1ea 100644 --- a/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts @@ -156,6 +156,7 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar let failoverStatusVisible = false; function commitAssistantRoundMeta(assistant: AssistantMessage, round: number) { + const contextUsageTokens = compaction.observeContextMessages([assistant]); gatewayBridgeEvents.queueToken("", { round, provider: assistant.provider, @@ -163,6 +164,7 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar api: assistant.api, stopReason: assistant.stopReason, usage: assistant.usage, + contextUsageTokens, }); batchLiveRoundsUpdate( (prev) => @@ -175,6 +177,7 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar stopReason: String(assistant.stopReason ?? ""), usage: assistant.usage, usageTotalTokens: assistant.usage?.totalTokens, + contextUsageTokens, }, })), transcriptStore, @@ -244,6 +247,10 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar }); pendingTextContext = null; compaction.beginRequest(contextWithSkills, getNextConversationState()); + gatewayBridgeEvents.queueToken("", { + round: textRound, + contextUsageTokens: compaction.contextUsageTokens, + }); hookLifecycle.startTurn(textRound); textModeUsesLiveRounds = false; diff --git a/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs b/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs index 75a099baa..cb089ba96 100644 --- a/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs +++ b/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs @@ -211,6 +211,7 @@ test("agent turn preserves suppressed parent Agent trace for cancellation persis compaction: { async maybeCompactPreSend() {}, beginRequest: noOp, + observeContextMessages: () => 0, shouldProtectMidStream: () => false, async compactDuringRun() { return { context: null, shouldDisableProtection: false }; @@ -382,6 +383,7 @@ test("AskUserQuestion becomes visible only when execution starts while ordinary compaction: { async maybeCompactPreSend() {}, beginRequest: noOp, + observeContextMessages: () => 0, shouldProtectMidStream() { protectionChecks += 1; return false; diff --git a/crates/agent-gui/test/chat/compaction-controller.test.mjs b/crates/agent-gui/test/chat/compaction-controller.test.mjs index 1deecb960..2bcae3b41 100644 --- a/crates/agent-gui/test/chat/compaction-controller.test.mjs +++ b/crates/agent-gui/test/chat/compaction-controller.test.mjs @@ -99,7 +99,8 @@ function createSinksRecorder() { applyStateMidRun: (state) => events.push(["applyStateMidRun", state]), publishStatus: (status) => events.push(["publishStatus", status]), setBridgeToolStatus: (text, isCompaction) => events.push(["bridge", text, isCompaction]), - queueCheckpoint: (state) => events.push(["queueCheckpoint", state]), + queueCheckpoint: (state, contextUsageTokens) => + events.push(["queueCheckpoint", state, contextUsageTokens]), persist: async (state) => { events.push(["persist", state]); return true; @@ -529,3 +530,339 @@ test("registry hands out one controller per conversation and disposes cleanly", registry.dispose("conv-a"); assert.notEqual(registry.get("conv-a"), a); }); + +test("beginRequest exposes the current total and dynamic fixed-token snapshot", () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "x".repeat(400), + messages: [], + }); + + controller.beginRequest(conversationState.buildRequestContext(state), state); + + assert.deepEqual(controller.contextUsageSnapshot, { + totalTokens: 100, + fixedTokens: 100, + }); +}); + +// —— 手动压缩(用量环入口)—— + +function manualBinding(overrides = {}) { + const cancellation = cancellationModule.createTurnCancellation(); + const recorder = createSinksRecorder(); + return { + recorder, + binding: { + providerId: "anthropic", + model: "claude-x", + runtime: { + baseUrl: "https://example", + apiKey: "k", + modelConfig: { contextWindow: 200_000, maxOutputToken: 32_000 }, + }, + cancellation, + sinks: recorder.sinks, + complete: async () => summaryResponse(), + buildPreparedContext: (state, _tools, options) => + conversationState.buildRequestContext(state, options), + buildResumeContext: (state, resumeMessage, _tools, options) => { + const context = conversationState.buildRequestContext(state, options); + return resumeMessage + ? { ...context, messages: [...context.messages, resumeMessage] } + : context; + }, + ...overrides, + }, + }; +} + +test("compactManually skips below the 50% manual threshold", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + assistantWithUsage("working on src/app.ts", 99_999, 2), + ], + }); + const { binding, recorder } = manualBinding(); + + const result = await controller.compactManually(binding, state); + + assert.deepEqual(result, { status: "skipped", reason: "below-manual-threshold" }); + assert.equal(recorder.byKind("publishStatus").length, 0); + assert.equal(recorder.byKind("persist").length, 0); +}); + +test("compactManually compacts at 50%, bypasses the automatic threshold, and unbinds", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + assistantWithUsage("working on src/app.ts", 100_000, 2), + ], + }); + const { binding, recorder } = manualBinding(); + + const result = await controller.compactManually(binding, state); + + assert.deepEqual(result, { status: "compacted" }); + const statuses = recorder.byKind("publishStatus").map(([, status]) => status.phase); + assert.deepEqual(statuses, ["running", "completed"]); + assert.equal(recorder.byKind("persist").length, 1); + assert.equal(recorder.byKind("queueCheckpoint").length, 1); + const [, checkpointState, checkpointTokens] = recorder.byKind("queueCheckpoint")[0]; + assert.equal( + checkpointState.segments[checkpointState.activeSegmentIndex].summary.summaryMeta.stats + .contextTokensAfter, + checkpointTokens, + ); + assert.ok(checkpointTokens > 0); + const [, appliedState] = recorder.byKind("applyStateMidRun")[0]; + assert.equal(appliedState.segments.length, 2); + // running 时 bridge isCompaction=true,结束后清 null。 + const bridgeEvents = recorder.byKind("bridge"); + assert.equal(bridgeEvents[0][2], true); + assert.equal(bridgeEvents.at(-1)[1], null); + // 解绑后可再次手动压缩(不被残留 binding 卡成 busy)。 + assert.notEqual( + (await controller.compactManually(manualBinding().binding, bigState())).status, + "busy", + ); +}); + +test("compactManually honors the persisted usage snapshot and fixed-token anchor", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + assistantWithUsage("working on src/app.ts", 1_000, 2), + ], + }); + const { binding, recorder } = manualBinding(); + + const result = await controller.compactManually(binding, state, { + totalTokens: 100_000, + fixedTokens: 40_000, + }); + + assert.deepEqual(result, { status: "compacted" }); + const [, checkpointState, checkpointTokens] = recorder.byKind("queueCheckpoint")[0]; + assert.ok(checkpointTokens >= 40_000, "checkpoint keeps the persisted dynamic fixed overhead"); + assert.equal( + checkpointState.segments[checkpointState.activeSegmentIndex].summary.summaryMeta.stats + .contextTokensAfter, + checkpointTokens, + ); +}); + +test("compactManually refuses while a turn is bound or a compaction is in flight", async () => { + const controller = new CompactionController(); + bindController(controller); + const { binding } = manualBinding(); + assert.deepEqual(await controller.compactManually(binding, bigState()), { status: "busy" }); +}); + +test("compactManually keeps the disabled hard guard (zero context window)", async () => { + const controller = new CompactionController(); + let completeCalls = 0; + const { binding, recorder } = manualBinding({ + runtime: { baseUrl: "https://example", apiKey: "k", modelConfig: undefined }, + complete: async () => { + completeCalls += 1; + return summaryResponse(); + }, + }); + + const result = await controller.compactManually(binding, bigState()); + + assert.deepEqual(result, { status: "skipped", reason: "disabled" }); + assert.equal(completeCalls, 0); + assert.equal(recorder.byKind("publishStatus").length, 0); +}); + +test("manual compaction failure never prunes or applies state to an idle conversation", async () => { + const controller = new CompactionController(); + // 含可剪枝大工具输出的状态:run 时触发会走 prune 降级,manual(空闲会话) + // 绝不允许——prune 结果不持久化,一旦 apply 即内存与磁盘分叉。 + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + toolResultBig(200_000, 2), + user("continue with src/app.ts", 3), + user("check src/app.ts again", 4), + assistantWithUsage("working on src/app.ts", 190_000, 5), + ], + }); + const { binding, recorder } = manualBinding({ + complete: async () => { + throw new Error("invalid api key"); + }, + }); + + const result = await controller.compactManually(binding, state); + + assert.deepEqual(result, { status: "failed" }); + assert.equal(recorder.byKind("applyStateMidRun").length, 0); + assert.equal(recorder.byKind("applyState").length, 0); + assert.equal(recorder.byKind("persist").length, 0); + const statuses = recorder.byKind("publishStatus").map(([, status]) => status.phase); + assert.deepEqual(statuses, ["running", "failed"]); + const failedStatus = recorder + .byKind("publishStatus") + .map(([, status]) => status) + .find((status) => status.phase === "failed"); + assert.match(failedStatus.message, /invalid api key/); + assert.equal(recorder.byKind("bridge").at(-1)[1], null); +}); + +test("manual compaction skip after a prior completed compaction is not misreported", async () => { + const controller = new CompactionController(); + // 第一次成功压缩把控制器的 statusPhase 留在 completed(生命周期字段,跨操作残留)。 + const firstState = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + assistantWithUsage("working on src/app.ts", 150_000, 2), + ], + }); + assert.deepEqual(await controller.compactManually(manualBinding().binding, firstState), { + status: "compacted", + }); + + // 第二次低于 50% 门槛:探针拒绝。结果必须按本次调用的显式 outcome 报告, + // 不得被残留的 completed 误报成 compacted。 + const secondState = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [user("hi", 1), assistantWithUsage("hello", 42_000, 2)], + }); + const { binding, recorder } = manualBinding(); + const second = await controller.compactManually(binding, secondState); + + assert.deepEqual(second, { status: "skipped", reason: "below-manual-threshold" }); + assert.equal(recorder.byKind("publishStatus").length, 0); + assert.equal(recorder.byKind("persist").length, 0); + + // 更深一层:执行路径自身的二次裁决 skip 也必须走显式 outcome 通道—— + // 决策拒绝不 publish 任何状态,残留的 completed 不得参与结果判定。 + const { binding: directBinding } = manualBinding(); + controller.bindTurn(directBinding); + const direct = await controller.compactDuringRun({ + trigger: "manual", + state: secondState, + manualContextUsage: { totalTokens: 1_000 }, + }); + controller.unbindTurn(); + assert.equal(direct.outcome, "skipped"); + assert.equal(direct.reason, "below-manual-threshold"); + assert.equal(direct.context, null); +}); + +test("a rejected manual probe leaves the shared usage ledger untouched", async () => { + const controller = new CompactionController(); + const activeState = bigState(); + controller.beginRequest(conversationState.buildRequestContext(activeState), activeState); + const before = controller.contextUsageTokens; + assert.ok(before > 0); + + const probeState = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [user("hi", 1), assistantWithUsage("hello", 42_000, 2)], + }); + const result = await controller.compactManually(manualBinding().binding, probeState); + + assert.equal(result.status, "skipped"); + // 共享账本是用量环的读数真源:被拒的探测不得在其上留下任何残留。 + assert.equal(controller.contextUsageTokens, before); +}); + +test("compactManually threads tools into probe and checkpoint builds and fires onProceed once", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + assistantWithUsage("working on src/app.ts", 150_000, 2), + ], + }); + const tools = [{ name: "Read", description: "read files", parameters: {} }]; + const seenTools = []; + const { binding, recorder } = manualBinding({ + buildPreparedContext: (viewState, builtTools, options) => { + seenTools.push(builtTools); + return conversationState.buildRequestContext(viewState, options); + }, + }); + let proceedCalls = 0; + + const result = await controller.compactManually(binding, state, undefined, { + tools, + onProceed: () => { + proceedCalls += 1; + // onProceed 在探针通过之后、running 状态发布之前同步触发。 + assert.equal(recorder.byKind("publishStatus").length, 0); + }, + }); + + assert.deepEqual(result, { status: "compacted" }); + assert.equal(proceedCalls, 1); + // 探针、预算、checkpoint 三次构建都拿到同一份工具集(checkpoint 估值 + // 缺了工具重量会系统性偏低)。 + assert.ok(seenTools.length >= 3); + assert.ok(seenTools.every((entry) => entry === tools)); +}); + +test("compactManually does not fire onProceed when the probe rejects", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [user("hi", 1), assistantWithUsage("hello", 42_000, 2)], + }); + let proceedCalls = 0; + + const result = await controller.compactManually(manualBinding().binding, state, undefined, { + onProceed: () => { + proceedCalls += 1; + }, + }); + + assert.equal(result.status, "skipped"); + assert.equal(proceedCalls, 0); +}); + +test("compactManually reports aborted=true when the user stops mid-compaction", async () => { + const controller = new CompactionController(); + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "sys", + messages: [ + user("please fix src/app.ts", 1), + assistantWithUsage("working on src/app.ts", 150_000, 2), + ], + }); + const { binding, recorder } = manualBinding({ + complete: (params) => + new Promise((_, reject) => { + params.signal?.addEventListener("abort", () => { + const error = new Error("aborted by user"); + error.name = "AbortError"; + reject(error); + }); + }), + }); + + const pending = controller.compactManually(binding, state); + await new Promise((resolve) => setImmediate(resolve)); + binding.cancellation.userStop.abort(); + const result = await pending; + + assert.deepEqual(result, { status: "failed", aborted: true }); + // 统一善后:回滚快照消费(补持久化)、running 复位 idle、bridge 清空。 + const statuses = recorder.byKind("publishStatus").map(([, status]) => status.phase); + assert.deepEqual(statuses, ["running", "idle"]); + assert.equal(recorder.byKind("persistRollback").length, 1); + assert.equal(recorder.byKind("bridge").at(-1)[1], null); +}); diff --git a/crates/agent-gui/test/chat/compaction-token-ledger.test.mjs b/crates/agent-gui/test/chat/compaction-token-ledger.test.mjs index 19292369b..06506dfa2 100644 --- a/crates/agent-gui/test/chat/compaction-token-ledger.test.mjs +++ b/crates/agent-gui/test/chat/compaction-token-ledger.test.mjs @@ -157,6 +157,86 @@ test("rebase anchors on the latest real usage and estimates the trailing message assert.equal(snapshot.totalTokens, snapshot.observedTokens + snapshot.trailingTokens); }); +test("persisted usage anchors adjust when current system and tools fixed tokens change", () => { + const ledger = new TokenLedger(); + const observed = assistant("answer", usage(5_000)); + const originalContext = { + systemPrompt: "s".repeat(400), + tools: [], + messages: [user("question")], + }; + ledger.rebase(originalContext); + ledger.addMessages([observed]); + assert.equal(ledger.total(), 5_000); + + const changedContext = { + systemPrompt: "s".repeat(4_000), + tools: [{ name: "LargeTool", description: "d".repeat(4_000), parameters: {} }], + messages: [...originalContext.messages, observed], + }; + const originalFixed = estimateTextTokens(originalContext.systemPrompt); + const changedFixed = + estimateTextTokens(changedContext.systemPrompt) + ledgerModule.estimateToolsTokens(changedContext.tools); + ledger.rebase(changedContext); + + assert.equal(ledger.total(), 5_000 + changedFixed - originalFixed); + assert.equal(ledger.snapshot().hasFixedTokenAnchor, true); +}); + +test("legacy usage without fixed metadata trusts the observed total over estimates", () => { + const ledger = new TokenLedger(); + const legacyObserved = assistant("answer", usage(1_000)); + // PR 之前持久化的会话:有真实 usage 但无 liveAgentContextUsage 印章。 + // 估算口径有意高估(序列化字符 / CJK 密度),绝不允许覆盖真实读数—— + // 否则环读数会超 100% 并触发自动压缩循环。 + ledger.rebase({ + systemPrompt: "s".repeat(400_000), + tools: [{ name: "LargeTool", description: "d".repeat(400_000), parameters: {} }], + messages: [legacyObserved], + }); + + assert.equal(ledger.total(), 1_000); + assert.equal(ledger.snapshot().hasObservedUsage, true); + assert.equal(ledger.snapshot().hasFixedTokenAnchor, false); +}); + +test("real usage anchors are never overridden by the full-history estimate", () => { + const ledger = new TokenLedger(); + // 100 万字符的工具输出估算约 25 万 token,真实 usage 只有 5000:读数恒信 usage。 + ledger.rebase({ + systemPrompt: "sys", + messages: [toolResult("x".repeat(1_000_000)), assistant("done", usage(5_000))], + }); + + assert.equal(ledger.total(), 5_000); +}); + +test("assistant messages without provider usage are never stamped with estimates", () => { + const ledger = new TokenLedger(); + const noUsage = assistant("answer", usage(0)); + ledger.rebase({ systemPrompt: "s".repeat(400), messages: [user("question")] }); + const totalBefore = ledger.total(); + ledger.addMessages([noUsage]); + + // 印章随会话持久化且读取侧优先于 usage:估算一旦盖章会永久遮蔽后到的 + // 真实读数。无 usage 的消息只走 trailing 估算,不产生印章。 + assert.equal(noUsage.liveAgentContextUsage, undefined); + assert.equal(getMessageObservedTokens(noUsage), undefined); + assert.equal(ledger.total(), totalBefore + estimateMessageTokens(noUsage)); +}); + +test("assistant messages with real usage are stamped as fixed-token anchors", () => { + const ledger = new TokenLedger(); + ledger.rebase({ systemPrompt: "s".repeat(400), messages: [user("question")] }); + const observed = assistant("answer", usage(5_000)); + ledger.addMessages([observed]); + + // 印章只记录 usage 派生的权威值 + 当时的 fixed 开销(供跨端 rebase 补偿)。 + assert.deepEqual(observed.liveAgentContextUsage, { totalTokens: 5_000, fixedTokens: 100 }); + assert.equal(ledger.total(), 5_000); + assert.equal(ledger.snapshot().hasFixedTokenAnchor, true); +}); + test("rebase without any usage falls back to fixed + estimates", () => { const ledger = new TokenLedger(); const message = user("a".repeat(400)); diff --git a/crates/agent-gui/test/chat/context-usage.test.mjs b/crates/agent-gui/test/chat/context-usage.test.mjs new file mode 100644 index 000000000..209ea8043 --- /dev/null +++ b/crates/agent-gui/test/chat/context-usage.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const contextUsage = loader.loadModule("@liveagent/ui/lib/chat/contextUsage.ts"); +const tokenLedger = loader.loadModule("src/lib/chat/compaction/tokenLedger.ts"); +const chatComposerBarSource = readFileSync( + new URL("../../../agent-ui/src/pages/chat/ChatComposerBar.tsx", import.meta.url), + "utf8", +); +const chatTurnQueueSource = readFileSync( + new URL("../../src/pages/chat/queue/useChatTurnQueue.ts", import.meta.url), + "utf8", +); +const gatewayAppSource = readFileSync( + new URL("../../../agent-gateway/web/src/app/GatewayApp.tsx", import.meta.url), + "utf8", +); + +const { + CONTEXT_USAGE_WARN_RATIO, + CONTEXT_USAGE_DANGER_RATIO, + buildContextUsageScanItems, + contextUsageLevel, + canManualCompact, + contextUsageRatio, + deriveContextUsageTokens, + estimateTextTokens, +} = contextUsage; + +test("threshold boundaries: <50% ok, 50-80% warn, >=80% danger", () => { + assert.equal(CONTEXT_USAGE_WARN_RATIO, 0.5); + assert.equal(CONTEXT_USAGE_DANGER_RATIO, 0.8); + assert.equal(contextUsageLevel(0), "ok"); + assert.equal(contextUsageLevel(0.49), "ok"); + assert.equal(contextUsageLevel(0.5), "warn"); + assert.equal(contextUsageLevel(0.79), "warn"); + assert.equal(contextUsageLevel(0.8), "danger"); + assert.equal(contextUsageLevel(1.5), "danger"); +}); + +test("manual compaction unlocks exactly at the warn ratio", () => { + assert.equal(canManualCompact(0.49), false); + assert.equal(canManualCompact(0.5), true); + assert.equal(canManualCompact(0.99), true); +}); + +test("WebUI manual compaction targets the requested conversation and only accepts on proceed", () => { + assert.doesNotMatch(chatTurnQueueSource, /conversation is not active on desktop/); + // 严格 operationId:缺失即拒绝,不回退 requestId(回退产生 WebUI 从未登记的 + // operationId,终态永不匹配、挂满超时)。 + assert.match(chatTurnQueueSource, /manual compaction requires operationId/); + // 受理只在探针通过、真正开始压缩时经 onAccepted 同步回包。 + assert.match( + chatTurnQueueSource, + /manualCompactActionRef\s*\.current\(\{\s*conversationId,\s*operationId,\s*onAccepted: respondAccepted,?\s*\}\)/, + ); + // 探针拒绝据返回值同步回 accepted:false + message(不再受理即回包)。 + assert.match( + chatTurnQueueSource, + /fail\(result\.message \|\| "manual compaction declined", codeFor\(result\.status\)\)/, + ); +}); + +test("WebUI manual compaction converges from the transcript store and a bounded timeout", () => { + assert.match(gatewayAppSource, /store\.getSnapshot\(\)\.manualCompactionResult/); + assert.match(gatewayAppSource, /MANUAL_COMPACTION_TIMEOUT_MS/); + assert.match(gatewayAppSource, /chat\.manualCompactTimedOut/); +}); + +test("context usage ring stays vertically centered in the shared composer", () => { + assert.match( + chatComposerBarSource, + /className="absolute right-3 top-1\/2 z-20 -translate-y-1\/2"/, + ); + assert.doesNotMatch(chatComposerBarSource, /className="absolute bottom-11 right-3 z-20"/); +}); + +test("contextUsageRatio guards degenerate inputs", () => { + assert.equal(contextUsageRatio(100_000, 200_000), 0.5); + assert.equal(contextUsageRatio(undefined, 200_000), 0); + assert.equal(contextUsageRatio(100_000, undefined), 0); + assert.equal(contextUsageRatio(100_000, 0), 0); + assert.equal(contextUsageRatio(-1, 200_000), 0); + assert.equal(contextUsageRatio(Number.NaN, 200_000), 0); +}); + +test("deriveContextUsageTokens reads the newest assistant round usage", () => { + const items = [ + { kind: "user" }, + { + kind: "assistant", + rounds: [{ meta: { usageTotalTokens: 10_000 } }, { meta: { usageTotalTokens: 12_000 } }], + }, + { kind: "user" }, + { + kind: "assistant", + rounds: [{ meta: {} }, { meta: { usageTotalTokens: 34_000 } }, { meta: {} }], + }, + ]; + assert.equal(deriveContextUsageTokens(items), 34_000); +}); + +test("deriveContextUsageTokens prefers the runtime context snapshot over provider usage", () => { + assert.equal( + deriveContextUsageTokens([ + { + kind: "assistant", + rounds: [ + { meta: { usageTotalTokens: 10_000, contextUsageTokens: 150_000 }, blocks: [] }, + ], + }, + ]), + 150_000, + ); +}); + +test("deriveContextUsageTokens ignores render-only assistant rounds", () => { + const items = [ + { kind: "assistant", rounds: [{ meta: { contextUsageTokens: 150_000 }, blocks: [] }] }, + { + kind: "assistant", + rounds: [ + { + meta: { + contextRelevant: false, + usageTotalTokens: 10_000, + contextUsageTokens: 10_000, + }, + blocks: [{ kind: "text", text: "memory extraction status" }], + }, + ], + }, + ]; + assert.equal(deriveContextUsageTokens(items), 150_000); +}); + +test("deriveContextUsageTokens adds messages and tool results after the newest usage", () => { + const trailingUser = "x".repeat(80_000); + const toolResultContent = [{ type: "text", text: "y".repeat(4_000) }]; + const items = [ + { + kind: "assistant", + rounds: [ + { + meta: { usageTotalTokens: 100_000 }, + blocks: [ + { + kind: "tool", + item: { + toolCall: { name: "Read", arguments: { path: "src/app.ts" } }, + toolResult: { content: toolResultContent }, + }, + }, + ], + }, + ], + }, + { kind: "user", text: trailingUser }, + ]; + const toolResultTokens = + Math.ceil(contextUsage.estimateTextTokenUnits(JSON.stringify(toolResultContent))) + 8; + assert.equal(deriveContextUsageTokens(items), 120_008 + toolResultTokens); +}); + +test("deriveContextUsageTokens falls back to checkpoint estimate after compaction", () => { + const summaryText = "摘要正文 summary body".repeat(50); + // GUI 检查点(kind:"summary")与 WebUI 检查点(kind:"checkpoint")同口径。 + for (const kind of ["summary", "checkpoint"]) { + const items = [ + { kind: "assistant", rounds: [{ meta: { usageTotalTokens: 190_000 } }] }, + { kind, content: summaryText }, + ]; + const derived = deriveContextUsageTokens(items); + assert.equal(derived, estimateTextTokens(summaryText)); + assert.ok(derived > 0, "checkpoint estimate must keep the ring alive"); + assert.ok(derived < 190_000, "estimate must reflect the freed context"); + } +}); + +test("deriveContextUsageTokens prefers checkpoint fixed overhead and adds its trailing messages", () => { + const items = [ + { kind: "checkpoint", content: "short summary", contextUsageTokens: 40_000 }, + { kind: "user", text: "x".repeat(4_000) }, + ]; + assert.equal(deriveContextUsageTokens(items), 41_008); +}); + +test("deriveContextUsageTokens returns undefined without any usage", () => { + assert.equal(deriveContextUsageTokens([]), undefined); + assert.equal(deriveContextUsageTokens([{ kind: "user" }]), undefined); + assert.equal( + deriveContextUsageTokens([{ kind: "assistant", rounds: [{ meta: {} }] }]), + undefined, + ); +}); + +test("estimateTextTokens keeps the CJK-aware estimate after the move to shared", () => { + // tokenLedger re-export 与共享层实现必须是同一函数(迁移不改口径)。 + assert.equal(tokenLedger.estimateTextTokens, estimateTextTokens); + assert.equal(estimateTextTokens(""), 0); + assert.equal(estimateTextTokens(" "), 0); + // 4 个西文字符 ≈ 1 token;CJK 每字 0.7 token(向上取整)。 + assert.equal(estimateTextTokens("abcd"), 1); + assert.equal(estimateTextTokens("你好世界"), Math.ceil(4 * 0.7)); + // 可加性:分段和 = 整体(同一字符串拼接)。 + const west = "hello world "; + const cjk = "上下文压缩"; + assert.equal( + Math.ceil( + contextUsage.estimateTextTokenUnits(west) + contextUsage.estimateTextTokenUnits(cjk), + ), + estimateTextTokens(west + cjk), + ); +}); + +test("deriveContextTokens includes system, tools, and messages without an observed usage", () => { + const context = { + systemPrompt: "system instructions", + tools: [{ name: "Read", description: "read a file", parameters: { type: "object" } }], + messages: [{ role: "user", content: "continue", timestamp: 1 }], + }; + assert.equal( + tokenLedger.deriveContextTokens(context), + estimateTextTokens(context.systemPrompt) + + tokenLedger.estimateToolsTokens(context.tools) + + tokenLedger.estimateMessageTokens(context.messages[0]), + ); +}); + +test("buildContextUsageScanItems appends live rounds so streaming anchors the ring in real time", () => { + const history = [{ kind: "user", text: "hi" }]; + const liveRounds = [ + { + round: 1, + key: "r1", + blocks: [], + meta: { usageTotalTokens: 120_000 }, + runningToolCallIds: [], + thinkingOpen: false, + }, + ]; + const items = buildContextUsageScanItems(history, { + liveRounds, + draftAssistantText: "", + }); + assert.equal(items.length, 2); + assert.equal(deriveContextUsageTokens(items), 120_000); + // 空闲(无 live)时原样透传历史项。 + assert.equal(buildContextUsageScanItems(history, null), history); +}); + +test("buildContextUsageScanItems counts the streaming draft as a trailing round", () => { + const draft = "x".repeat(4_000); + const items = buildContextUsageScanItems( + [{ kind: "assistant", rounds: [{ meta: { usageTotalTokens: 50_000 } }] }], + { liveRounds: [], draftAssistantText: draft }, + ); + assert.equal( + deriveContextUsageTokens(items), + 50_000 + Math.ceil(contextUsage.estimateTextTokenUnits(draft)) + 8, + ); +}); diff --git a/crates/agent-gui/test/chat/conversation-state.test.mjs b/crates/agent-gui/test/chat/conversation-state.test.mjs index c67d02996..3d0954adb 100644 --- a/crates/agent-gui/test/chat/conversation-state.test.mjs +++ b/crates/agent-gui/test/chat/conversation-state.test.mjs @@ -689,3 +689,44 @@ test("model context sanitizer preserves user image content", () => { const requestContext = conversationState.buildRequestContext(state); assert.deepEqual(requestContext.messages[0].content, userImageMessage.content); }); + +test("timeline summary cards expose the persisted contextTokensAfter for the usage ring", () => { + const base = conversationState.createConversationStateFromContext({ + systemPrompt: "Base prompt", + messages: [user("hello", 1, { id: "u-1" }), assistant("world", 2, { id: "a-1" })], + }); + const compacted = conversationState.applyCompactionCheckpoint( + base, + checkpoint("checkpoint body", 3, "summary-usage"), + ); + // 压缩控制器在落定时把权威快照写进 stats.contextTokensAfter;投影必须原样 + // 暴露成 contextUsageTokens,两端用量环才共享同一检查点锚点。 + const withStats = { + ...compacted, + segments: compacted.segments.map((segment) => + segment.summary + ? { + ...segment, + summary: { + ...segment.summary, + summaryMeta: { + ...segment.summary.summaryMeta, + stats: { + ...(segment.summary.summaryMeta.stats ?? { sourceMessageCount: 2 }), + contextTokensAfter: 43_210, + }, + }, + }, + } + : segment, + ), + }; + const items = fullRuntimeTimeline(withStats); + const summaryItem = items.find((item) => item.kind === "summary"); + assert.equal(summaryItem.contextUsageTokens, 43_210); + + // 旧检查点没有该字段:不虚构读数,交由扫描器退回正文估算。 + const legacyItems = fullRuntimeTimeline(compacted); + const legacySummary = legacyItems.find((item) => item.kind === "summary"); + assert.equal("contextUsageTokens" in legacySummary, false); +}); diff --git a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs index 0e2633c8d..3c66a55e6 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs @@ -195,6 +195,50 @@ test("gateway bridge retry attempts ride tool_status with the current status and ); }); +test("gateway bridge emits correlated manual compaction terminal results", () => { + const { controller, sent } = createController(); + + controller.queueManualCompactionResult(" operation-1 ", "failed", " summary failed "); + + assert.deepEqual(sent, [ + { + requestId: "request-1", + event: { + type: "manual_compaction_result", + operationId: "operation-1", + status: "failed", + message: "summary failed", + conversation_id: "conversation-1", + }, + }, + ]); +}); + +test("gateway bridge manual compaction result swallows a rejected transport promise", async () => { + const rejection = new Error("ingress rejected"); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => { + warnings.push(args); + }; + try { + const controller = createGatewayBridgeEventController({ + conversationId: "conversation-1", + requestId: "request-1", + enabled: true, + sendEvent: () => Promise.reject(rejection), + }); + // The terminal result must not surface the transport failure synchronously, + // and the discarded delivery promise must be caught (no unhandled rejection). + controller.queueManualCompactionResult("operation-1", "compacted"); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][1], rejection); + } finally { + console.warn = originalWarn; + } +}); + test("gateway bridge close blocks normal events but allows forced title updates", () => { const { controller, sent } = createController(); @@ -312,7 +356,7 @@ test("gateway bridge checkpoint emits compaction summary payload", () => { }, }; - controller.queueCheckpoint(state); + controller.queueCheckpoint(state, 12_345); assert.deepEqual(sent, [ { @@ -335,6 +379,7 @@ test("gateway bridge checkpoint emits compaction summary payload", () => { model: "gpt-test", promptVersion: "summary-v2", }, + contextUsageTokens: 12_345, }, }, }, diff --git a/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs b/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs index 20ffb78c9..1aae7c81b 100644 --- a/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs +++ b/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs @@ -71,6 +71,32 @@ test("gateway runtime snapshot projects live rounds into chat entries", () => { assert.equal(entries[5].text, " Next step is ready."); }); +test("gateway runtime snapshots preserve authoritative usage and render-only metadata", () => { + const entries = buildGatewayRuntimeSnapshotEntries({ + userMessage: null, + liveTranscript: { + draftAssistantText: "", + toolStatus: null, + liveRounds: [ + { + key: "round-render-only", + round: 2, + meta: { contextUsageTokens: 150_000, contextRelevant: false }, + runningToolCallIds: [], + thinkingOpen: false, + blocks: [], + }, + ], + }, + }); + + assert.equal(entries.length, 1); + assert.equal(entries[0].kind, "assistant"); + assert.equal(entries[0].text, ""); + assert.equal(entries[0].meta.contextUsageTokens, 150_000); + assert.equal(entries[0].meta.contextRelevant, false); +}); + test("gateway runtime snapshot carries the same tool preview shape as bridge deltas", () => { const content = "z".repeat(9000); const toolCall = { diff --git a/crates/agent-gui/test/chat/messages.test.mjs b/crates/agent-gui/test/chat/messages.test.mjs index d667e72f7..d9d5d976c 100644 --- a/crates/agent-gui/test/chat/messages.test.mjs +++ b/crates/agent-gui/test/chat/messages.test.mjs @@ -374,6 +374,7 @@ test("UI message builder groups assistant rounds and attaches matching tool resu api: "openai-responses", stopReason: "stop", usage: { totalTokens: 42 }, + liveAgentContextUsage: { totalTokens: 84, fixedTokens: 20 }, timestamp: 4, }, ]; @@ -387,6 +388,7 @@ test("UI message builder groups assistant rounds and attaches matching tool resu assert.equal(uiMessages.getRoundThinkingText(ui[1].rounds[0]), "checking"); assert.equal(uiMessages.getRoundToolTrace(ui[1].rounds[0])[0].toolResult.content[0].text, "file contents"); assert.equal(ui[1].rounds[1].meta.usageTotalTokens, 42); + assert.equal(ui[1].rounds[1].meta.contextUsageTokens, 84); }); test("UI message builder preserves provider hosted search blocks", () => { diff --git a/crates/agent-gui/test/chat/transcript-row-model.test.mjs b/crates/agent-gui/test/chat/transcript-row-model.test.mjs index 1e27ae213..f2aa6fc42 100644 --- a/crates/agent-gui/test/chat/transcript-row-model.test.mjs +++ b/crates/agent-gui/test/chat/transcript-row-model.test.mjs @@ -592,3 +592,116 @@ test("transcript virtualizer keeps scroll updates off the full React measurement assert.doesNotMatch(transcriptListSource, /height:\s*virtualizer\.getTotalSize\(\)/); assert.doesNotMatch(transcriptListSource, /transform:\s*`translateY\(/); }); + +test("a status-only live tail (idle manual compaction) closes without a stranded settling row", () => { + const model = createTranscriptRowModel(); + const history = [userItem("u1"), assistantItem("a1", [round("r1", "reply")])]; + + // 手动压缩空闲态:TranscriptList 以 isCompactionRunning 激活 live tail(不置 + // isSending,这正是发布出去的真实状态形状),经 LiveTailInput.isCompactionRunning + // 走可见性 gate;live store 只有 toolStatus——live 行是纯状态行(CompactingText), + // 没有内容块。 + const compacting = model.build(history, { + ...idleLive, + isSending: false, + isCompactionRunning: true, + toolStatus: "正在压缩上下文…", + }); + const compactingTail = compacting.rows.at(-1); + assert.equal(compactingTail.kind, "assistant-activity"); + assert.equal(compactingTail.units.length, 1); + assert.equal(compactingTail.units[0].unit.kind, "status"); + + // 压缩落定:历史被重排成检查点卡片(没有可收养的 assistant 孪生项)。 + // 无内容的 live 轮必须直接收尾,不能留下冻结的 settling 状态行。 + const compactedHistory = [ + { + kind: "summary", + key: "summary-seg-1", + segmentIndex: 1, + summaryId: "s1", + content: "checkpoint body", + coveredMessageCount: 2, + coversThroughMessageId: "m2", + generatedBy: { providerId: "openai", model: "gpt-test" }, + timestamp: 3, + collapsed: true, + }, + ]; + const closed = model.build(compactedHistory, idleLive); + assert.equal(closed.liveStartIndex, -1); + assert.equal(closed.rows.length, 1); + assert.equal(closed.rows[0].kind, "summary"); + + const stable = model.build(compactedHistory, idleLive); + assert.equal(stable.rows.length, 1); +}); + +test("a cancelled run's abort-notice twin is adopted by the live turn (no remount)", () => { + const model = createTranscriptRowModel(); + const history = [userItem("u1")]; + + // 被取消的 run:内容在取消瞬间尚未成块(这里以纯状态 live tail 模拟), + // live tail 没有任何可见 block 单元——producedContent 为 false。 + const streaming = model.build(history, { + ...idleLive, + isSending: true, + toolStatus: "…", + }); + assert.equal(blockRows(streaming).length, 0); + const liveActivity = streaming.rows.at(-1); + assert.equal(liveActivity.kind, "assistant-activity"); + const liveTurnKey = liveActivity.replyKey; + assert.match(liveTurnKey, /^live-turn-/); + + // 取消落定:中止提示 assistant 项持久化为孪生行(有真实文本内容)。 + const settledHistory = [userItem("u1"), assistantItem("a1", [round("r1", "partial final")])]; + const settled = model.build(settledHistory, idleLive); + + // 孪生行必须被同一 live turn 领养:以 streaming renderMode 渲染、包在一个 + // activity 行里、key 沿用 live turn 的 replyKey(零 remount),而不是以新的 + // static key 重挂载。 + assert.equal(settled.liveStartIndex, -1); + const settledActivity = settled.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(settledActivity, "the abort-notice twin must be adopted into a streaming activity row"); + assert.equal(settledActivity.replyKey, liveTurnKey); + const twinBlocks = blockRows(settled); + assert.equal(twinBlocks.length, 1); + assert.equal(twinBlocks[0].renderMode, "streaming"); + assert.ok(twinBlocks[0].key.startsWith(liveTurnKey)); +}); + +test("a Task-only run's twin (all blocks filtered) is adopted by the live turn (no remount)", () => { + const model = createTranscriptRowModel(); + const taskTool = { + kind: "tool", + item: { toolCall: { type: "toolCall", id: "task-1", name: "TaskCreate", arguments: {} } }, + }; + const history = [userItem("u1")]; + + // 仅输出 Task 工具的 run:块被 isVisibleGroupedBlock 全部过滤,live tail 没有 + // 任何可见 block 单元(只剩状态行)——producedContent 为 false。 + const streaming = model.build(history, { + ...idleLive, + isSending: true, + liveRounds: [{ round: 1, key: "r1", blocks: [taskTool], runningToolCallIds: [], thinkingOpen: false }], + }); + assert.equal(blockRows(streaming).length, 0); + const liveTurnKey = streaming.rows.at(-1).replyKey; + assert.match(liveTurnKey, /^live-turn-/); + + // 落定:任务列表更新的 assistant 项持久化为孪生行(块同样被过滤)。孪生行必须 + // 被 live turn 领养 → 渲染成一个 streaming activity 行、replyKey 沿用 live turn, + // 而不是以新的 static key 重挂载。 + const settledHistory = [ + userItem("u1"), + assistantItem("a1", [{ round: 1, key: "r1", blocks: [taskTool] }]), + ]; + const settled = model.build(settledHistory, idleLive); + assert.equal(settled.liveStartIndex, -1); + const settledActivity = settled.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(settledActivity, "the Task-only twin must be adopted into a streaming activity row"); + assert.equal(settledActivity.replyKey, liveTurnKey); + assert.ok(settledActivity.units.every((unit) => unit.renderMode === "streaming")); +}); + diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 9f87de18b..7020d630a 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -421,11 +421,11 @@ test("chat runtime controls default and follow provider model reasoning support" }), ["minimal", "low", "medium", "high"], ); - // gemini-3-pro-preview:目录只有两档 low/high。 + // gemini-3-pro-image:目录只有两档 low/high。 assert.deepEqual( settings.getChatRuntimeReasoningLevelsForProvider({ providerId: "gemini", - modelId: "gemini-3-pro-preview", + modelId: "gemini-3-pro-image", }), ["low", "high"], ); diff --git a/crates/agent-gui/test/subagents/harness.mjs b/crates/agent-gui/test/subagents/harness.mjs index 0364490ba..e7587d233 100644 --- a/crates/agent-gui/test/subagents/harness.mjs +++ b/crates/agent-gui/test/subagents/harness.mjs @@ -313,6 +313,10 @@ export function createDefaultCompactionMock(compactionCalls) { beginRequest() {} + observeContextMessages() { + return 0; + } + shouldProtectMidStream() { return false; } diff --git a/crates/agent-ui/src/components/chat/ContextUsageRing.tsx b/crates/agent-ui/src/components/chat/ContextUsageRing.tsx new file mode 100644 index 000000000..d612ba3ce --- /dev/null +++ b/crates/agent-ui/src/components/chat/ContextUsageRing.tsx @@ -0,0 +1,130 @@ +import { Meter } from "@base-ui/react"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { cn } from "@liveagent/ui/lib/shared/utils"; +import { + canManualCompact, + contextUsageLevel, + contextUsageRatio, +} from "../../lib/chat/contextUsage"; +import { ConfirmActionPopover } from "../ui/confirm-action-popover"; +import { LabelTooltip } from "../ui/label-tooltip"; + +const RING_STROKE_BY_LEVEL = { + ok: "stroke-emerald-500 dark:stroke-emerald-400", + warn: "stroke-amber-500 dark:stroke-amber-400", + danger: "stroke-red-500 dark:stroke-red-400", +} as const; + +// Intl.NumberFormat 构造含 locale 数据解析,环随流式读数逐帧重渲染, +// 必须按 locale 复用实例。 +const tokenFormatterByLocale = new Map(); + +function getTokenFormatter(locale: string): Intl.NumberFormat { + const cached = tokenFormatterByLocale.get(locale); + if (cached) return cached; + const formatter = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); + tokenFormatterByLocale.set(locale, formatter); + return formatter; +} + +/** + * 上下文用量环:composer 内展示当前会话上下文占用百分比,占用 ≥ 50%(黄档) + * 起可点击弹出确认后触发手动压缩。数据口径见 lib/chat/contextUsage.ts。 + * 语义用 Meter(静态量度)而非 Progress(任务进度)。 + */ +export function ContextUsageRing(props: { + totalTokens?: number; + contextWindow?: number; + disabled?: boolean; + onConfirm?: (() => void) | (() => Promise); + className?: string; +}) { + const { totalTokens, contextWindow, disabled, onConfirm, className } = props; + const { t, locale } = useLocale(); + if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) { + return null; + } + + const ratio = contextUsageRatio(totalTokens, contextWindow); + // 只保留两个口径:展示值(取整、封顶 999)与画环/量度值(0-100 钳制, + // 二者共用避免 a11y 量度与弧线漂移)。contextUsageRatio 不会返回负数。 + const displayedPercentage = Math.min(999, Math.round(ratio * 100)); + const clampedPercentage = Math.min(100, ratio * 100); + const formatTokens = getTokenFormatter(locale); + const usageLabel = `${displayedPercentage}% · ${t("chat.usageTotal")} ${formatTokens.format( + Math.max(0, Math.floor(totalTokens ?? 0)), + )} · ${t("chat.contextWindow")} ${formatTokens.format(contextWindow)}`; + const compactAvailable = canManualCompact(ratio) && !disabled && Boolean(onConfirm); + + const ring = ( + + + {displayedPercentage}% + + ); + + if (!compactAvailable) { + return ( + + + {ring} + + + ); + } + + return ( + + void onConfirm?.()} + > + {(open) => ( + + )} + + + ); +} diff --git a/crates/agent-ui/src/components/ui/label-tooltip.tsx b/crates/agent-ui/src/components/ui/label-tooltip.tsx new file mode 100644 index 000000000..38364e367 --- /dev/null +++ b/crates/agent-ui/src/components/ui/label-tooltip.tsx @@ -0,0 +1,31 @@ +import { Tooltip } from "@base-ui/react"; +import type { ReactNode } from "react"; + +/** + * 纯文本标签气泡(Base UI):composer 运行时控件与上下文用量环共用同一视觉。 + * z-index 必须挂在 Positioner 上(Popup 上无效,见弹层拓扑约定)。 + */ +export function LabelTooltip(props: { label: string; children: ReactNode }) { + return ( + + {props.children}} + /> + + + + {props.label} + + + + + ); +} diff --git a/crates/agent-ui/src/lib/chat/contextUsage.ts b/crates/agent-ui/src/lib/chat/contextUsage.ts new file mode 100644 index 000000000..201b20471 --- /dev/null +++ b/crates/agent-ui/src/lib/chat/contextUsage.ts @@ -0,0 +1,242 @@ +// 上下文用量的两端单一真源:颜色分档阈值、手动压缩门槛,以及 WebUI 从 +// transcript 倒扫补算 trailing 消息的口径。GUI 直接使用运行时 TokenLedger, +// WebUI 使用这里的同源估算与桌面同步的 checkpoint 权威快照。 +// +// CJK 感知的文本 token 估算也定义在此(原 agent-gui compaction/tokenLedger.ts, +// 迁入共享层供压缩检查点估值复用;tokenLedger 从这里 re-export 保持旧调用方不动)。 + +/** 黄色起点,同时是手动压缩可用的起点(issue #359:占用 ≥50% 才允许压缩)。 */ +export const CONTEXT_USAGE_WARN_RATIO = 0.5; +/** 红色起点。 */ +export const CONTEXT_USAGE_DANGER_RATIO = 0.8; + +export type ContextUsageLevel = "ok" | "warn" | "danger"; + +export function contextUsageLevel(ratio: number): ContextUsageLevel { + if (ratio >= CONTEXT_USAGE_DANGER_RATIO) return "danger"; + if (ratio >= CONTEXT_USAGE_WARN_RATIO) return "warn"; + return "ok"; +} + +export function canManualCompact(ratio: number): boolean { + return ratio >= CONTEXT_USAGE_WARN_RATIO; +} + +export function contextUsageRatio( + totalTokens: number | undefined, + contextWindow: number | undefined, +): number { + if ( + typeof totalTokens !== "number" || + !Number.isFinite(totalTokens) || + totalTokens <= 0 || + typeof contextWindow !== "number" || + !Number.isFinite(contextWindow) || + contextWindow <= 0 + ) { + return 0; + } + return totalTokens / contextWindow; +} + +const CHARS_PER_TOKEN = 4; +// CJK 文字的 token 密度远高于西文:主流 tokenizer(o200k/cl100k/Claude)大约 +// 每 1.4~1.7 个汉字 1 token。按 chars/4 估会低估约 2.5~3 倍,导致压缩触发 +// 严重偏晚甚至撞上下文上限。取 0.7 token/字作为偏保守(宁早勿晚)的估计。 +const CJK_TOKENS_PER_CHAR = 0.7; + +// CJK 统一表意文字(含扩展 A)、假名、谚文、兼容表意/形式与全角标点。 +// 这些区段全部落在 BMP,按 UTF-16 code unit 判断即可;增补平面字符 +// (emoji 等)按两个西文字符计入 chars/4 路径。 +function isCjkCodeUnit(code: number): boolean { + return ( + (code >= 0x2e80 && code <= 0x9fff) || + (code >= 0xac00 && code <= 0xd7af) || + (code >= 0x1100 && code <= 0x11ff) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe30 && code <= 0xfe4f) || + (code >= 0xff00 && code <= 0xffef) + ); +} + +/** + * 文本的分数 token 估算(不 trim、不取整)。按字符类别累加:CJK 字符按 + * CJK_TOKENS_PER_CHAR,其余按 1/CHARS_PER_TOKEN。可加性成立:对任意切分, + * 分段估算之和恒等于整体估算,因此流式增量可按 delta 累加。 + */ +export function estimateTextTokenUnits(text: string): number { + let cjkChars = 0; + for (let index = 0; index < text.length; index += 1) { + if (isCjkCodeUnit(text.charCodeAt(index))) cjkChars += 1; + } + return (text.length - cjkChars) / CHARS_PER_TOKEN + cjkChars * CJK_TOKENS_PER_CHAR; +} + +export function estimateTextTokens(text: string): number { + const normalized = text.trim(); + if (!normalized) return 0; + return Math.ceil(estimateTextTokenUnits(normalized)); +} + +// 两端 transcript 项的最小结构投影:GUI RenderTimelineItem(检查点 kind:"summary") +// 与 WebUI TranscriptRow(检查点 kind:"checkpoint")经结构化类型直接传入。 +export type ContextUsageScanItem = { + kind: string; + text?: string; + rounds?: readonly { + meta?: { + usageTotalTokens?: number; + contextUsageTokens?: number; + contextRelevant?: boolean; + }; + blocks?: readonly { + kind?: string; + text?: string; + item?: unknown; + }[]; + }[]; + content?: string; + contextUsageTokens?: number; +}; + +export type ContextUsageLiveTail = { + liveRounds: NonNullable; + draftAssistantText: string; +}; + +export function buildContextUsageScanItems( + historyItems: readonly ContextUsageScanItem[], + live: ContextUsageLiveTail | null, +): readonly ContextUsageScanItem[] { + if (!live) return historyItems; + if (live.liveRounds.length > 0) { + return [...historyItems, { kind: "assistant", rounds: live.liveRounds }]; + } + if (live.draftAssistantText) { + return [ + ...historyItems, + { + kind: "assistant", + rounds: [{ blocks: [{ kind: "text", text: live.draftAssistantText }] }], + }, + ]; + } + return historyItems; +} + +// 逐消息估算只统计正文字符,补一个小常量近似 JSON 包裹(role/键名/引号)的 +// 开销。两端(GUI TokenLedger 与 WebUI 倒扫)共用此口径,调参只改这里。 +export const MESSAGE_ENVELOPE_TOKENS = 8; + +// 非文本值的估算口径(字符串直估,其余 JSON 序列化后估)。GUI TokenLedger +// 与 WebUI 倒扫共用,保证两端对同一负载的估算一致。 +export function stringifiedTokenUnits(value: unknown): number { + if (typeof value === "string") return estimateTextTokenUnits(value); + if (value == null) return 0; + try { + const serialized = JSON.stringify(value); + return serialized ? estimateTextTokenUnits(serialized) : 0; + } catch { + return estimateTextTokenUnits(String(value)); + } +} + +function messageTokensFromUnits(units: number): number { + return Math.ceil(Math.max(0, units)) + MESSAGE_ENVELOPE_TOKENS; +} + +// 两端 store 都按不可变更新替换工具结果对象,估算结果可按对象身份缓存; +// 流式期间倒扫逐帧执行,没有这层缓存会对大工具结果每帧重复 JSON.stringify。 +const toolResultTokenCache = new WeakMap(); + +function estimateToolResultTokens(result: { content?: unknown; details?: unknown }): number { + const cached = toolResultTokenCache.get(result); + if (cached !== undefined) return cached; + const tokens = messageTokensFromUnits( + stringifiedTokenUnits(result.content) + stringifiedTokenUnits(result.details), + ); + toolResultTokenCache.set(result, tokens); + return tokens; +} + +function estimateRoundTokens( + round: NonNullable[number], + onlyToolResults: boolean, +): number { + let assistantUnits = 0; + let toolResultTokens = 0; + for (const block of round.blocks ?? []) { + if (block.kind === "tool") { + const item = + block.item && typeof block.item === "object" + ? (block.item as { + toolCall?: { name?: string; arguments?: unknown }; + toolResult?: { content?: unknown; details?: unknown }; + }) + : undefined; + const toolCall = item?.toolCall; + if (!onlyToolResults && toolCall) { + assistantUnits += + stringifiedTokenUnits(toolCall.name) + stringifiedTokenUnits(toolCall.arguments); + } + const toolResult = item?.toolResult; + if (toolResult) toolResultTokens += estimateToolResultTokens(toolResult); + continue; + } + if (!onlyToolResults) { + assistantUnits += + typeof block.text === "string" + ? estimateTextTokenUnits(block.text) + : stringifiedTokenUnits(block); + } + } + if (onlyToolResults) return toolResultTokens; + return (assistantUnits > 0 ? messageTokensFromUnits(assistantUnits) : 0) + toolResultTokens; +} + +// "有效 token 计数"的两端单一校验口径(floor 且必须是有限正数)。 +export function positiveTokenCount(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +/** + * 倒扫 transcript 求当前上下文占用:最近一个 assistant 轮次的真实 API usage + * 是锚点(usage.totalTokens 已含该 assistant 输出及其之前的 system/tools/历史), + * 再累加锚点之后的用户消息、后续 assistant 内容与工具结果。压缩检查点优先使用 + * 桌面端同步的权威 contextUsageTokens;旧历史没有该字段时才退回摘要正文估算。 + */ +export function deriveContextUsageTokens( + items: readonly ContextUsageScanItem[], +): number | undefined { + let trailingTokens = 0; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind === "summary" || item.kind === "checkpoint") { + const checkpointTokens = + positiveTokenCount(item.contextUsageTokens) ?? + (typeof item.content === "string" ? estimateTextTokens(item.content) : undefined); + return checkpointTokens === undefined ? undefined : checkpointTokens + trailingTokens; + } + if (item.kind === "user") { + if (typeof item.text === "string" && item.text.trim()) { + trailingTokens += estimateTextTokens(item.text) + MESSAGE_ENVELOPE_TOKENS; + } + continue; + } + if (item.kind !== "assistant" || !item.rounds) continue; + for (let roundIndex = item.rounds.length - 1; roundIndex >= 0; roundIndex -= 1) { + const round = item.rounds[roundIndex]; + if (round?.meta?.contextRelevant === false) continue; + const totalTokens = + positiveTokenCount(round?.meta?.contextUsageTokens) ?? + positiveTokenCount(round?.meta?.usageTotalTokens); + if (totalTokens !== undefined) { + return totalTokens + trailingTokens + estimateRoundTokens(round, true); + } + trailingTokens += estimateRoundTokens(round, false); + } + } + return trailingTokens > 0 ? trailingTokens : undefined; +} diff --git a/crates/agent-ui/src/lib/sidebar/transientActivity.ts b/crates/agent-ui/src/lib/sidebar/transientActivity.ts new file mode 100644 index 000000000..115f589b8 --- /dev/null +++ b/crates/agent-ui/src/lib/sidebar/transientActivity.ts @@ -0,0 +1,43 @@ +import { workspaceProjectPathKey } from "@liveagent/app/lib/settings"; + +export type TransientSidebarRunningConversation = { + conversationId: string; + workdir?: string | null; +}; + +export function mergeTransientSidebarRunningActivity( + runningConversationIds: ReadonlySet, + runningProjectPathKeys: ReadonlySet, + transients: + | readonly (TransientSidebarRunningConversation | null | undefined)[] + | TransientSidebarRunningConversation + | null + | undefined, +): { + runningConversationIds: ReadonlySet; + runningProjectPathKeys: ReadonlySet; +} { + // 支持多个同时“转圈”的瞬态会话(issue #359 缺陷 #3):手动压缩 pending 已按 + // 会话 id 键化,多个后台会话可同时压缩。既接受数组,也向后兼容单对象入参。 + const list = Array.isArray(transients) + ? transients + : transients + ? [transients as TransientSidebarRunningConversation] + : []; + let nextConversationIds = runningConversationIds; + let nextProjectPathKeys = runningProjectPathKeys; + for (const transient of list) { + const conversationId = transient?.conversationId.trim() ?? ""; + const projectPathKey = workspaceProjectPathKey(transient?.workdir ?? ""); + if (conversationId && !nextConversationIds.has(conversationId)) { + nextConversationIds = new Set(nextConversationIds).add(conversationId); + } + if (projectPathKey && !nextProjectPathKeys.has(projectPathKey)) { + nextProjectPathKeys = new Set(nextProjectPathKeys).add(projectPathKey); + } + } + return { + runningConversationIds: nextConversationIds, + runningProjectPathKeys: nextProjectPathKeys, + }; +} diff --git a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx index 2c1b93e42..9e40629b6 100644 --- a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx @@ -1,4 +1,3 @@ -import { Tooltip } from "@base-ui/react"; import { ChevronDown, ChevronUp, @@ -24,6 +23,7 @@ import { type ReasoningLevel, } from "@liveagent/app/lib/settings"; import { ComposerAttachmentCard } from "@liveagent/ui/components/chat/ComposerAttachmentCard"; +import { ContextUsageRing } from "@liveagent/ui/components/chat/ContextUsageRing"; import { getUploadedFileTypeIcon } from "@liveagent/ui/components/chat/fileTypeIcons"; import { MentionComposer, @@ -32,6 +32,7 @@ import { } from "@liveagent/ui/components/chat/MentionComposer"; import { GitBranchSelector } from "@liveagent/ui/components/git/GitBranchSelector"; import { Button } from "@liveagent/ui/components/ui/button"; +import { LabelTooltip as RuntimeControlTooltip } from "@liveagent/ui/components/ui/label-tooltip"; import { Select, SelectContent, @@ -53,6 +54,7 @@ import { useLayoutEffect, useRef, useState, + useSyncExternalStore, } from "react"; import { getUploadedImagePreviewCacheKey, @@ -76,31 +78,6 @@ function isReasoningLevel(value: unknown): value is ReasoningLevel { return typeof value === "string" && Object.hasOwn(REASONING_I18N_KEYS, value); } -function RuntimeControlTooltip(props: { label: string; children: ReactNode }) { - return ( - - {props.children}} - /> - - - - {props.label} - - - - - ); -} - function useComposerUploadedImagePreview( file: PendingUploadedFile, workdir: string, @@ -211,6 +188,40 @@ const DEFAULT_QUEUE_SCROLLBAR_STATE: QueueScrollbarState = { const COMPOSER_EXPAND_ANIMATION_MS = 280; const COMPOSER_EXPAND_EASING = "cubic-bezier(0.32, 0.72, 0.22, 1)"; +/** 用量环实时读数订阅源(getContextUsageTokens 必须对同一底层状态返回稳定值)。 */ +export type ContextUsageTokensSource = { + subscribe: (listener: () => void) => () => void; + getContextUsageTokens: () => number | undefined; +}; + +const noopSubscribe = () => () => {}; + +// 环的实时读数在独立小组件里订阅:流式期间每帧的读数变化只重渲染这枚 +// SVG 环,不触发 ChatComposerBar/整页回流。 +function ComposerContextUsageRing(props: { + source?: ContextUsageTokensSource; + totalTokens?: number; + contextWindow?: number; + disabled?: boolean; + onConfirm?: (() => void) | (() => Promise); +}) { + const { source, totalTokens, contextWindow, disabled, onConfirm } = props; + const readStatic = useCallback(() => totalTokens, [totalTokens]); + const liveTokens = useSyncExternalStore( + source?.subscribe ?? noopSubscribe, + source?.getContextUsageTokens ?? readStatic, + source?.getContextUsageTokens ?? readStatic, + ); + return ( + + ); +} + function prefersReducedMotion() { return ( typeof window.matchMedia === "function" && @@ -234,6 +245,19 @@ export type ChatComposerBarProps = { gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + /** 当前会话上下文占用 token;与 contextWindow 齐备时显示用量环。 */ + contextUsageTokens?: number; + /** + * 可选的用量环实时订阅源:流式期间读数每帧都在变,经此订阅只重渲染环 + * 本身而不回流整页(GUI 用;WebUI 传静态 contextUsageTokens 即可)。 + * 提供时优先于 contextUsageTokens。 + */ + contextUsageTokensSource?: ContextUsageTokensSource; + contextWindow?: number; + /** 用量环确认后触发手动压缩;缺省时环为纯展示。 */ + onManualCompactConfirm?: (() => void) | (() => Promise); + /** 压缩进行中/请求在途时禁点用量环。 */ + manualCompactBlocked?: boolean; workspaceActivityClient?: WorkspaceActivityClient | null; onSend: () => void; onStop: () => void; @@ -276,6 +300,11 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer gitClient, gitWriteEnabled = true, gitDisabledMessage, + contextUsageTokens, + contextUsageTokensSource, + contextWindow, + onManualCompactConfirm, + manualCompactBlocked, workspaceActivityClient, onSend, onStop, @@ -894,6 +923,17 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer )} + {/* 用量环位于卡片右侧控制列的垂直中心,保持在展开与发送按钮之间。 */} +
+ +
+ {/* 常驻 flex-1:动画把卡片钳在中间高度时由本区吸收伸缩,工具栏才能 全程贴住卡片底边。min-h-0 只在展开态加——折叠态靠自动最小高度 (= 编辑器钳制高) 撑起卡片的固有高度,加了会塌缩。 */}