From 06c1d0b8145a1d18aee1b8347bbf3542fae0947b Mon Sep 17 00:00:00 2001 From: andershsueh Date: Sat, 15 Aug 2026 01:23:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(tui):=20token=20budget=20=E6=8E=A5?= =?UTF-8?q?=E9=80=9A=20TUI=20=E7=8A=B6=E6=80=81=E6=A0=8F(IK8MWR=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 tokenBudget 从死端接通到 Footer 状态栏: * tokenBudget:新增 getUsage(tracker, budget) → BudgetUsage 纯函数导出 (used/total/pct/remaining/nearCompletion/nearDiminishing) * chatStream:ChatStreamEvent 联合类型加 budget_update 字段 * runtimeEvents:RuntimeEvent 加 budget_update(承载 BudgetUsage) * llm.chatStreamWithTools:新增 onBudgetUpdate 回调, 每轮 checkTokenBudget 后通过 getUsage 上抛 * agentLoop:回调入 pendingBudgetUsage 队列,每个 chunk 到达前 flush + 循环结束 drain 一次,保证最后一帧不丢 * chatHandler:把 RuntimeEvent.budget_update 摊平转发到 ChatStreamEvent * useAliceStream:接收 budget_update 事件写 tokenBudget state + 暴露给 AppContainer * UIState:加 tokenBudget?: BudgetUsage | null 字段 * AppContainer:从 useGeminiStream 拿 tokenBudget 注入 UIState * TokenBudgetBar:新建 Ink 组件,渲染 `[ctx NN%]` 文本 + ⚠exh/⚠dim 警示,挂到 Footer.rightItems(tokenBudget 优先于 context) * test-case/test-issue-012.ts:4 组 41 断言覆盖 getUsage 边界、 ChatStreamEvent 类型联合、TokenBudgetBar 字符串、联调事件序列 * test-case/test-list.md:追加 #12 行 + 回归套件加入 012 --- src/core/llm.ts | 9 + src/daemon/chatHandler.ts | 12 ++ src/runtime/agent/agentLoop.ts | 20 ++ src/runtime/agent/tokenBudget.ts | 51 +++++ src/runtime/kernel/runtimeEvents.ts | 6 + src/shim/hooks/useAliceStream.ts | 15 ++ src/types/chatStream.ts | 10 + src/ui/AppContainer.tsx | 3 + src/ui/components/Footer.tsx | 11 +- src/ui/components/TokenBudgetBar.tsx | 40 ++++ src/ui/contexts/UIStateContext.tsx | 3 + test-case/test-issue-012.ts | 304 +++++++++++++++++++++++++++ test-case/test-list.md | 3 +- 13 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 src/ui/components/TokenBudgetBar.tsx create mode 100644 test-case/test-issue-012.ts diff --git a/src/core/llm.ts b/src/core/llm.ts index 0ba228f..87f63b7 100644 --- a/src/core/llm.ts +++ b/src/core/llm.ts @@ -11,6 +11,8 @@ import { createBudgetTracker, checkTokenBudget, estimateTokens, + getUsage, + type BudgetUsage, } from '../runtime/agent/tokenBudget.js'; import type { ModelRegistry } from '../daemon/modelRegistry.js'; @@ -264,12 +266,15 @@ export class LLMClient { * @param tokenBudget - 可选,本次任务允许的最大输出 token 数。 * 超过 80% 时发送 nudge,收益递减时主动停止。 * 为 null 或 0 时禁用 budget 管理。 + * @param onBudgetUpdate - 可选,每轮 checkTokenBudget 后回调(IK8MWR #12), + * 供上层把预算用量推给 TUI 状态栏。 */ async *chatStreamWithTools( messages: Message[], onToolUpdate?: (record: ToolCallRecord) => void, workspace?: string, tokenBudget?: number | null, + onBudgetUpdate?: (usage: BudgetUsage) => void, ): AsyncGenerator { if (!this.toolExecutor) { throw new Error('工具系统未启用'); @@ -337,6 +342,10 @@ export class LLMClient { // Token budget 检查(有工具调用时才有意义检查,因为还要继续循环) const budgetDecision = checkTokenBudget(budgetTracker, iterationOutputTokens, budget); + // 上报预算用量给上层(IK8MWR #12:供 TUI 状态栏消费) + if (onBudgetUpdate) { + onBudgetUpdate(getUsage(budgetTracker, budget)); + } // 有工具调用,添加到对话历史 const assistantMessage: Message = { diff --git a/src/daemon/chatHandler.ts b/src/daemon/chatHandler.ts index 51016df..52583b5 100644 --- a/src/daemon/chatHandler.ts +++ b/src/daemon/chatHandler.ts @@ -61,6 +61,18 @@ export async function* runChatStream( degraded: event.degraded, tier: event.tier, }; + } else if (event.type === 'budget_update') { + // IK8MWR #12:把 runtime 的 BudgetUsage 摊平到 ChatStreamEvent 字段, + // 客户端无需再 import runtime 层类型 + yield { + type: 'budget_update', + used: event.usage.used, + total: event.usage.total, + pct: event.usage.pct, + remaining: event.usage.remaining, + nearCompletion: event.usage.nearCompletion, + nearDiminishing: event.usage.nearDiminishing, + }; } else if (event.type === 'warning') { logger.warn('Runtime warning', event.warning.message); } else if (event.type === 'permission_denied') { diff --git a/src/runtime/agent/agentLoop.ts b/src/runtime/agent/agentLoop.ts index 9a59722..8a3b190 100644 --- a/src/runtime/agent/agentLoop.ts +++ b/src/runtime/agent/agentLoop.ts @@ -268,6 +268,10 @@ export async function* runAgentLoop( let accumulatedContent = ''; let lastYieldedNormalLength = 0; + // IK8MWR #12:预算用量回调不能直接在迭代器内部 yield(JS 协程限制), + // 改为排队,每个 chunk 到达后先 flush 队列再处理 chunk + let pendingBudgetUsage: import('../agent/tokenBudget.js').BudgetUsage | null = null; + try { for await (const chunk of client.chatStreamWithTools( messagesForLLM, @@ -276,7 +280,17 @@ export async function* runAgentLoop( }, session.workspace, tokenBudget, + // IK8MWR #12:把每轮预算用量上抛为 budget_update 事件,供 TUI 状态栏消费 + (usage) => { + pendingBudgetUsage = usage; + }, )) { + // 先把已排队的预算事件 flush 出去,保证事件顺序:预算 → 当前 chunk + if (pendingBudgetUsage) { + yield { type: 'budget_update', usage: pendingBudgetUsage }; + pendingBudgetUsage = null; + } + if (toolState.hasPending()) { const records = flushToolState(toolState, finalMessages, accumulatedContent); for (const record of records) { @@ -308,6 +322,12 @@ export async function* runAgentLoop( } } + // 循环结束:drain 剩余的预算事件(防止最后一次回调被吞) + if (pendingBudgetUsage) { + yield { type: 'budget_update', usage: pendingBudgetUsage }; + pendingBudgetUsage = null; + } + if (toolState.hasPending()) { const records = flushToolState(toolState, finalMessages, accumulatedContent); for (const record of records) { diff --git a/src/runtime/agent/tokenBudget.ts b/src/runtime/agent/tokenBudget.ts index 4f7ca1a..4ca5022 100644 --- a/src/runtime/agent/tokenBudget.ts +++ b/src/runtime/agent/tokenBudget.ts @@ -16,6 +16,22 @@ const COMPLETION_THRESHOLD = 0.8 */ const DIMINISHING_THRESHOLD = 200 +/** + * Token Budget 用量快照(供 TUI 状态栏消费) + * + * 设计:纯函数从 BudgetTracker 派生,不修改状态。 + * nearCompletion = 已用占比 ≥ 80%,提示"接近预算耗尽" + * nearDiminishing = 剩余 token < 200,提示"空间已不足,即将停止" + */ +export type BudgetUsage = { + used: number; + total: number; + pct: number; + remaining: number; + nearCompletion: boolean; + nearDiminishing: boolean; +} + /** 至少经历这么多轮才做收益递减判断 */ const DIMINISHING_MIN_ITERATIONS = 2 @@ -103,3 +119,38 @@ export function checkTokenBudget( export function estimateTokens(text: string): number { return Math.ceil(text.length / 4) } + +/** + * 从 BudgetTracker 派生当前用量快照(纯函数,不改 tracker) + * + * 用于把 token 预算进度暴露到 TUI 状态栏: + * - total 为 null/0/负:未启用 budget,返回 zero state + * - nearCompletion:pct ≥ 80%(提示用户即将耗尽) + * - nearDiminishing:remaining < 200(提示用户空间已不足) + */ +export function getUsage( + tracker: BudgetTracker, + budget: number | null, +): BudgetUsage { + if (budget === null || budget <= 0) { + return { + used: tracker.cumulativeOutputTokens, + total: 0, + pct: 0, + remaining: 0, + nearCompletion: false, + nearDiminishing: false, + } + } + const used = tracker.cumulativeOutputTokens + const remaining = Math.max(0, budget - used) + const pct = used / budget + return { + used, + total: budget, + pct, + remaining, + nearCompletion: pct >= COMPLETION_THRESHOLD, + nearDiminishing: remaining < DIMINISHING_THRESHOLD, + } +} diff --git a/src/runtime/kernel/runtimeEvents.ts b/src/runtime/kernel/runtimeEvents.ts index 7ed4e6b..0aae3cb 100644 --- a/src/runtime/kernel/runtimeEvents.ts +++ b/src/runtime/kernel/runtimeEvents.ts @@ -1,6 +1,7 @@ import type { Message, ModelCapabilityTier } from '../../types/index.js'; import type { ToolCallRecord } from '../../types/tool.js'; import type { RuntimeTurnSummary, RuntimeWarning } from './runtimeTypes.js'; +import type { BudgetUsage } from '../agent/tokenBudget.js'; export type RuntimeEvent = | { type: 'text_delta'; content: string } @@ -18,4 +19,9 @@ export type RuntimeEvent = modelName: string; degraded: boolean; tier: ModelCapabilityTier; + } + | { + /** Token 预算用量更新(IK8MWR #12):每轮工具循环后由 agentLoop 推送 */ + type: 'budget_update'; + usage: BudgetUsage; }; diff --git a/src/shim/hooks/useAliceStream.ts b/src/shim/hooks/useAliceStream.ts index 95adfcf..e9014f3 100644 --- a/src/shim/hooks/useAliceStream.ts +++ b/src/shim/hooks/useAliceStream.ts @@ -25,6 +25,7 @@ import type { ChatStreamEvent } from '../../types/chatStream.js'; import type { ToolCallRecord } from '../../types/tool.js'; import type { SlashCommandProcessorResult } from '../../ui/types.js'; import { formatToolResult } from '../../runtime/tools/toolResultFormatter.js'; +import type { BudgetUsage } from '../../runtime/agent/tokenBudget.js'; // ─── Tool call tracking ─────────────────────────────────────────────────────── @@ -80,6 +81,8 @@ export const useAliceStream = ( const [modelDegraded, setModelDegraded] = useState(false); /** 当前实际使用的模型名称(由 model_selected 事件更新) */ const [activeModelName, setActiveModelName] = useState(undefined); + /** IK8MWR #12:token 预算用量快照,由 budget_update 事件更新 */ + const [tokenBudget, setTokenBudget] = useState(null); // ── Refs ─────────────────────────────────────────────────────────────────── const abortControllerRef = useRef(null); @@ -135,6 +138,7 @@ export const useAliceStream = ( toolGroupIdRef.current = null; setToolCalls([]); setThought(null); + setTokenBudget(null); setStreamingState(StreamingState.Responding); @@ -195,6 +199,16 @@ export const useAliceStream = ( } else if (event.type === 'model_selected') { setModelDegraded(event.degraded); setActiveModelName(event.modelName); + } else if (event.type === 'budget_update') { + // IK8MWR #12:把 daemon 上报的预算用量写入 hook 状态,供 AppContainer 注入 UIState + setTokenBudget({ + used: event.used, + total: event.total, + pct: event.pct, + remaining: event.remaining, + nearCompletion: event.nearCompletion, + nearDiminishing: event.nearDiminishing, + }); } }, []); @@ -292,5 +306,6 @@ export const useAliceStream = ( loopDetectionConfirmationRequest: null, modelDegraded, activeModelName, + tokenBudget, }; }; diff --git a/src/types/chatStream.ts b/src/types/chatStream.ts index e3851bf..94266df 100644 --- a/src/types/chatStream.ts +++ b/src/types/chatStream.ts @@ -37,4 +37,14 @@ export type ChatStreamEvent = degraded: boolean; /** 当前路由到的能力层 */ tier: import('./index.js').ModelCapabilityTier; + } + | { + /** Token 预算用量更新(IK8MWR #12):每轮工具循环后由 daemon 推送 */ + type: 'budget_update'; + used: number; + total: number; + pct: number; + remaining: number; + nearCompletion: boolean; + nearDiminishing: boolean; }; diff --git a/src/ui/AppContainer.tsx b/src/ui/AppContainer.tsx index 07c8409..a2e31c9 100644 --- a/src/ui/AppContainer.tsx +++ b/src/ui/AppContainer.tsx @@ -655,6 +655,7 @@ export const AppContainer = (props: AppContainerProps) => { loopDetectionConfirmationRequest, modelDegraded, activeModelName, + tokenBudget, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1409,6 +1410,7 @@ export const AppContainer = (props: AppContainerProps) => { currentModel, currentModelDegraded: modelDegraded, activeModelName, + tokenBudget, contextFileNames, availableTerminalHeight, mainAreaWidth, @@ -1523,6 +1525,7 @@ export const AppContainer = (props: AppContainerProps) => { currentModel, modelDegraded, activeModelName, + tokenBudget, extensionsUpdateState, activePtyId, historyManager, diff --git a/src/ui/components/Footer.tsx b/src/ui/components/Footer.tsx index af81f6a..07fa52a 100644 --- a/src/ui/components/Footer.tsx +++ b/src/ui/components/Footer.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; +import { TokenBudgetBar } from './TokenBudgetBar.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; @@ -24,9 +25,10 @@ export const Footer: React.FC = () => { const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); - const { promptTokenCount, showAutoAcceptIndicator } = { + const { promptTokenCount, showAutoAcceptIndicator, tokenBudget } = { promptTokenCount: uiState.sessionStats.lastPromptTokenCount, showAutoAcceptIndicator: uiState.showAutoAcceptIndicator, + tokenBudget: uiState.tokenBudget, }; const { columns: terminalWidth } = useTerminalSize(); @@ -93,6 +95,13 @@ export const Footer: React.FC = () => { ), }); } + // IK8MWR #12:token 预算用量,优先于 context 显示(更精确) + if (tokenBudget && tokenBudget.total > 0) { + rightItems.push({ + key: 'token-budget', + node: , + }); + } return ( = ({ + usage, +}) => { + if (!usage || usage.total <= 0) { + return null; + } + const pct = Math.round(usage.pct * 100); + // diminishing 优先级最高(模型即将被强制停止) + let suffix = ''; + let color = theme.text.secondary; + if (usage.nearDiminishing) { + suffix = ' ⚠dim'; + color = theme.status.error; + } else if (usage.nearCompletion) { + suffix = ' ⚠exh'; + color = theme.status.warning; + } + return ( + + [ctx {pct}%]{suffix} + + ); +}; diff --git a/src/ui/contexts/UIStateContext.tsx b/src/ui/contexts/UIStateContext.tsx index d13a77b..9b0d3c6 100644 --- a/src/ui/contexts/UIStateContext.tsx +++ b/src/ui/contexts/UIStateContext.tsx @@ -29,6 +29,7 @@ import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { ExtensionUpdateState } from '../state/extensions.js'; import type { UpdateObject } from '../utils/updateCheck.js'; +import type { BudgetUsage } from '../../runtime/agent/tokenBudget.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; @@ -100,6 +101,8 @@ export interface UIState { currentModelDegraded?: boolean; /** 当前实际使用的模型名称(由 model_selected 事件驱动,与 currentModel 可能不同) */ activeModelName?: string; + /** IK8MWR #12:token 预算用量快照,由 budget_update 事件驱动;null 表示未启用预算 */ + tokenBudget?: BudgetUsage | null; contextFileNames: string[]; availableTerminalHeight: number | undefined; mainAreaWidth: number; diff --git a/test-case/test-issue-012.ts b/test-case/test-issue-012.ts new file mode 100644 index 0000000..4a89610 --- /dev/null +++ b/test-case/test-issue-012.ts @@ -0,0 +1,304 @@ +/** + * test-case/test-issue-012.ts + * + * 对应 issue IK8MWR #12 Token Budget 接通 TUI 状态栏 + * + * 运行: bun run test-case/test-issue-012.ts + * + * 测试方法(issue 原文): + * ① getUsage() 边界:pct ≥ 0.8 时 nearCompletion=true;剩余 < 200 token 时 + * nearDiminishing=true;中间值正确返回 + * ② budget_update 事件并入 ChatStreamEvent 联合类型后,所有既有用法仍可编译 + * 且不漏处理(类型断言 + 现有 5 类事件 assertTypeMatch) + * ③ TokenBudgetBar render 输出包含 `[ctx NN%]` 文本(纯函数计算字符串断言) + * ④ 整轮联调:模拟预算耗尽,通过 checkTokenBudget 后用 getUsage 取出数值, + * 验证 useAliceStream 能拿到 budget_update 数据结构并写 UIState + */ + +import { + createBudgetTracker, + checkTokenBudget, + getUsage, +} from '../src/runtime/agent/tokenBudget.js'; +import type { ChatStreamEvent } from '../src/types/chatStream.js'; +import type { BudgetUsage } from '../src/runtime/agent/tokenBudget.js'; + +// ---------- 极简测试 harness ---------- + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function assert(cond: unknown, msg: string): void { + if (cond) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(msg); + console.log(` ✗ ${msg}`); + } +} + +function section(name: string): void { + console.log(`\n── ${name} ──`); +} + +// ---------- 用例 ① getUsage() 边界 ---------- + +function testGetUsage(): void { + section('① getUsage() 边界:nearCompletion / nearDiminishing / 中间值'); + + // 空 tracker,无预算 → zero state + const zero = createBudgetTracker(); + const zeroUsage = getUsage(zero, null); + assert(zeroUsage.used === 0 && zeroUsage.total === 0 && zeroUsage.pct === 0, + `无预算时 zero state (实际 ${JSON.stringify(zeroUsage)})`); + assert(zeroUsage.nearCompletion === false && zeroUsage.nearDiminishing === false, + '无预算时两个 flag 都 false'); + + // 中间值:40% / budget 1000 + const t = createBudgetTracker(); + checkTokenBudget(t, 400, 1000); + const mid = getUsage(t, 1000); + assert(mid.used === 400 && mid.total === 1000, + `中间值 used/total 正确 (实际 used=${mid.used},total=${mid.total})`); + assert(Math.abs(mid.pct - 0.4) < 1e-9, + `pct = 0.4 (实际 ${mid.pct})`); + assert(mid.nearCompletion === false, + '40% 时 nearCompletion = false'); + assert(mid.nearDiminishing === false, + '剩余 600 时 nearDiminishing = false(> 200)'); + + // 80% 边界:nearCompletion 触发 + const t80 = createBudgetTracker(); + checkTokenBudget(t80, 800, 1000); + const u80 = getUsage(t80, 1000); + assert(u80.pct >= 0.8, `pct 触发阈值 (实际 ${u80.pct})`); + assert(u80.nearCompletion === true, 'pct ≥ 0.8 时 nearCompletion = true'); + assert(u80.remaining === 200, `remaining 字段正确 (实际 ${u80.remaining})`); + + // 剩余 < 200:nearDiminishing 触发 + const tDim = createBudgetTracker(); + checkTokenBudget(tDim, 850, 1000); + const uDim = getUsage(tDim, 1000); + assert(uDim.remaining === 150, `remaining < 200 (实际 ${uDim.remaining})`); + assert(uDim.nearDiminishing === true, + 'remaining < 200 时 nearDiminishing = true(即使 pct 不到 0.8)'); + + // 收益递减触发:连续两轮 < 200 输出 + const tDec = createBudgetTracker(); + // 第一轮:大输出 + checkTokenBudget(tDec, 500, 10000); + // 第二轮:小输出 + const d2 = checkTokenBudget(tDec, 100, 10000); + assert(d2.action === 'continue', '第二轮 small but alone 不算 diminishing'); + // 第三轮:又小输出 → 两轮连续小 + const d3 = checkTokenBudget(tDec, 100, 10000); + assert(d3.action === 'stop' && d3.reason === 'diminishing_returns', + '连续两轮小输出 → diminishing_returns 停止'); + const uDec = getUsage(tDec, 10000); + // 注意:nearDiminishing 是看 remaining (< 200),而不是看每轮输出大小。 + // 当前 cumulative = 700 / 10000,remaining = 9300,所以 nearDiminishing = false。 + // "收益递减触发" 是决策层语义,getUsage 只反映 budget 剩余空间。 + assert(uDec.nearDiminishing === false && uDec.remaining === 9300, + 'diminishing 触发 ≠ remaining < 200(语义层 vs 预算层,实际 remaining=9300)'); + assert(typeof uDec.pct === 'number', 'pct 字段始终为 number'); + + // 100% 边界 + const tFull = createBudgetTracker(); + checkTokenBudget(tFull, 1000, 1000); + const uFull = getUsage(tFull, 1000); + assert(uFull.pct === 1.0 && uFull.remaining === 0, + `满预算 pct=1.0,remaining=0 (实际 ${JSON.stringify(uFull)})`); + assert(uFull.nearCompletion === true && uFull.nearDiminishing === true, + '满预算时两个 flag 都 true'); +} + +// ---------- 用例 ② ChatStreamEvent 类型扩展后既有用法仍可编译 ---------- + +function testChatStreamEventUnion(): void { + section('② ChatStreamEvent 类型联合:budget_update 已加入'); + + // 5 类原有事件 + 新事件全部能正确 narrow + const samples: ChatStreamEvent[] = [ + { type: 'text', content: 'hello' }, + { + type: 'tool_call', + record: { + id: 'r1', + toolName: 'bash', + params: {}, + status: 'success', + result: { output: 'ok' }, + }, + }, + { type: 'done', sessionId: 's1', messages: [] }, + { type: 'error', message: 'oops' }, + { type: 'model_selected', modelName: 'gpt-x', degraded: false, tier: 'code' }, + // 新事件:budget_update + { + type: 'budget_update', + used: 800, + total: 1000, + pct: 0.8, + remaining: 200, + nearCompletion: true, + nearDiminishing: false, + }, + ]; + + // 每条事件都能被按 type narrow 编译通过(否则 tsc --noEmit 会失败) + let budgetSeen = false; + let textCount = 0; + for (const e of samples) { + switch (e.type) { + case 'text': textCount++; break; + case 'tool_call': assert(e.record.toolName === 'bash', 'tool_call narrow'); break; + case 'done': assert(e.sessionId === 's1', 'done narrow'); break; + case 'error': assert(e.message === 'oops', 'error narrow'); break; + case 'model_selected': assert(e.degraded === false, 'model_selected narrow'); break; + case 'budget_update': + assert(e.used === 800, 'budget_update 字段 used'); + assert(e.nearCompletion === true, 'budget_update 字段 nearCompletion'); + budgetSeen = true; + break; + } + } + + assert(samples.length === 6, `联合类型共 6 种事件 (实际 ${samples.length})`); + assert(textCount === 1, 'text narrow 命中'); + assert(budgetSeen, 'budget_update narrow 命中'); + + // 类型契约:BudgetUsage 形状契约(独立类型断言) + const fakeUsage: BudgetUsage = { + used: 100, + total: 1000, + pct: 0.1, + remaining: 900, + nearCompletion: false, + nearDiminishing: false, + }; + assert(typeof fakeUsage.pct === 'number' && typeof fakeUsage.nearCompletion === 'boolean', + 'BudgetUsage 类型契约:数字/布尔'); +} + +// ---------- 用例 ③ TokenBudgetBar render 字符串断言 ---------- + +function testTokenBudgetBar(): void { + section('③ TokenBudgetBar 文本格式:`[ctx NN%]` / 警示后缀'); + + // 模拟 TokenBudgetBar 内部的纯函数(避免引入 ink 测试运行环境) + // 验证:输入数字 → 输出格式字符串 + function renderBar(usage: BudgetUsage): string { + if (!usage || usage.total <= 0) return ''; + const pct = Math.round(usage.pct * 100); + let suffix = ''; + if (usage.nearDiminishing) suffix = ' ⚠dim'; + else if (usage.nearCompletion) suffix = ' ⚠exh'; + return `[ctx ${pct}%]` + suffix; + } + + assert(renderBar({ used: 0, total: 0, pct: 0, remaining: 0, nearCompletion: false, nearDiminishing: false }) === '', + '无预算时不显示'); + assert(renderBar({ used: 400, total: 1000, pct: 0.4, remaining: 600, nearCompletion: false, nearDiminishing: false }) === '[ctx 40%]', + '40% 输出 `[ctx 40%]`'); + assert(renderBar({ used: 800, total: 1000, pct: 0.8, remaining: 200, nearCompletion: true, nearDiminishing: false }) === '[ctx 80%] ⚠exh', + '80% 输出 `[ctx 80%] ⚠exh`'); + assert(renderBar({ used: 950, total: 1000, pct: 0.95, remaining: 50, nearCompletion: true, nearDiminishing: true }) === '[ctx 95%] ⚠dim', + 'diminishing 优先于 exhausted'); + assert(renderBar({ used: 100, total: 1000, pct: 0.1, remaining: 900, nearCompletion: false, nearDiminishing: false }) === '[ctx 10%]', + '10% 输出 `[ctx 10%]`(无警示)'); +} + +// ---------- 用例 ④ 联调:循环里 yield 出的 budget_update 事件序列 ---------- + +function testLoopIntegration(): void { + section('④ 联调:跑满一个 budget 周期后,事件序列符合预期'); + + // 模拟 llm.chatStreamWithTools 内部循环:每轮 checkTokenBudget, + // 通过 getUsage 取出数据 → emit budget_update 事件 + const tracker = createBudgetTracker(); + const events: ChatStreamEvent[] = []; + const BUDGET = 1000; + + // 选大输出避免触发 diminishing_returns(连续两轮 < 200 才停), + // 让循环跑满三轮被 exhausted 停止 + const outs = [500, 200, 200]; // 累计 900/1000 = 90% → exhausted 停止 + for (const out of outs) { + const decision = checkTokenBudget(tracker, out, BUDGET); + const usage = getUsage(tracker, BUDGET); + events.push({ + type: 'budget_update', + used: usage.used, + total: usage.total, + pct: usage.pct, + remaining: usage.remaining, + nearCompletion: usage.nearCompletion, + nearDiminishing: usage.nearDiminishing, + }); + if (decision.action === 'stop') break; + } + + assert(events.length === 3, `3 轮输出 3 个 budget_update 事件 (实际 ${events.length})`); + assert(events[0]!.type === 'budget_update' && (events[0] as any).pct === 0.5, + '第 1 轮 pct = 0.5'); + assert((events[1] as any).pct === 0.7, '第 2 轮累计 pct = 0.7'); + assert((events[2] as any).pct === 0.9, + '第 3 轮累计 pct = 0.9'); + assert((events[2] as any).nearCompletion === true, + '第 3 轮 ≥ 0.8 → nearCompletion'); + assert((events[0] as any).nearDiminishing === false && (events[1] as any).nearDiminishing === false, + '前两轮 remaining > 200 → nearDiminishing = false'); + assert((events[2] as any).nearDiminishing === true, + '第 3 轮 remaining=100 < 200 → nearDiminishing = true'); + + // 极小预算场景:收益递减 + const tracker2 = createBudgetTracker(); + const events2: ChatStreamEvent[] = []; + const BUDGET2 = 10000; + for (const out of [500, 100, 100]) { + const d = checkTokenBudget(tracker2, out, BUDGET2); + const u = getUsage(tracker2, BUDGET2); + events2.push({ + type: 'budget_update', + used: u.used, total: u.total, pct: u.pct, remaining: u.remaining, + nearCompletion: u.nearCompletion, nearDiminishing: u.nearDiminishing, + }); + if (d.action === 'stop') break; + } + assert(events2.length === 3, '收益递减场景也产生 3 个事件'); + // 注意:第 3 轮 remaining = 9300,远大于 200,所以 nearDiminishing = false。 + // 收益递减是 llm.chatStreamWithTools 决策层(连续两轮输出小),不会反映在 BudgetUsage 字段里。 + assert((events2[2] as any).nearDiminishing === false && (events2[2] as any).remaining === 9300, + 'nearDiminishing 是预算剩余语义,不是输出大小(remaining=9300 > 200)'); +} + +// ---------- 主入口 ---------- + +async function main(): Promise { + console.log('🧪 test-issue-012 — Token Budget 接通 TUI 状态栏\n'); + + try { + testGetUsage(); + testChatStreamEventUnion(); + testTokenBudgetBar(); + testLoopIntegration(); + } catch (err) { + console.error('uncaught:', err); + failures.push('uncaught: ' + (err instanceof Error ? err.message : String(err))); + failed++; + } + + console.log(`\n────────────────────────────`); + console.log(`PASS: ${passed} FAIL: ${failed}`); + if (failed > 0) { + console.log('\n失败明细:'); + failures.forEach((m) => console.log(` - ${m}`)); + process.exit(1); + } else { + process.exit(0); + } +} + +void main(); diff --git a/test-case/test-list.md b/test-case/test-list.md index a654ce1..b8107a2 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -8,7 +8,7 @@ ```bash # issue 回归套件(当前基线 168 断言) -for t in 001 002 003 004 005 019; do bun run test-case/test-issue-$t.ts || exit 1; done +for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done ``` ## 清单(按 issue 编号排序) @@ -22,6 +22,7 @@ for t in 001 002 003 004 005 019; do bun run test-case/test-issue-$t.ts || exit | `test-issue-004.ts` | Feature Flag + 构建期 DCE:flag 开关、GrowthBookLocal、acp-integration 剥离字节 0 | 构建/runtime feature(build.ts、runtime/feature) | issue #4(IK8MWJ)/ PR !5 | | `test-issue-005.ts` | Workspace Backend 收敛守卫:daemon 不得直接 import *Backend 实现(grep + tsc 两层) | workspace 解耦(daemon、runtime/workspace) | issue #5(IK8MWK)/ PR !6 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | +| `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR 待回填 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | | `test-tools.ts` | 手动入口:toolRegistry / builtinTools / ToolExecutor 冒烟 | 工具系统 | 历史 dev 脚本(无 PR) | | `test-function-calling.ts` | 手动入口:LLM function calling 端到端(需真实 API) | function calling | 历史 dev 脚本(无 PR) | -- Gitee From 1c6741fdbe5601d7c613751ada89119cdcfb0397 Mon Sep 17 00:00:00 2001 From: andershsueh Date: Sat, 15 Aug 2026 01:25:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(test):=20test-list.md=20=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=20PR=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-case/test-list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-case/test-list.md b/test-case/test-list.md index b8107a2..815e848 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -22,7 +22,7 @@ for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || e | `test-issue-004.ts` | Feature Flag + 构建期 DCE:flag 开关、GrowthBookLocal、acp-integration 剥离字节 0 | 构建/runtime feature(build.ts、runtime/feature) | issue #4(IK8MWJ)/ PR !5 | | `test-issue-005.ts` | Workspace Backend 收敛守卫:daemon 不得直接 import *Backend 实现(grep + tsc 两层) | workspace 解耦(daemon、runtime/workspace) | issue #5(IK8MWK)/ PR !6 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | -| `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR 待回填 | +| `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | | `test-tools.ts` | 手动入口:toolRegistry / builtinTools / ToolExecutor 冒烟 | 工具系统 | 历史 dev 脚本(无 PR) | | `test-function-calling.ts` | 手动入口:LLM function calling 端到端(需真实 API) | function calling | 历史 dev 脚本(无 PR) | -- Gitee