diff --git a/FEATURES.md b/FEATURES.md index 7e4a8d7d..f0b60d72 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -788,7 +788,9 @@ records are listed separately and do not introduce another feature status. without a persistent legacy-cache truncation warning; previously lost history is not restored. Token usage is grouped by Runtime and model and normalizes the different OpenAI-compatible and Anthropic Messages - cache-reporting semantics when showing cache hit rate. Project and conversation + cache-reporting semantics when showing cache hit rate. OpenCode usage adds cache + reads and writes back to its uncached input count before calculating the rate, + including when querying existing records. Project and conversation usage groups show collapsed totals by default and expand to Runtime/model child rows; model grouping remains flat. A separate System tasks tab groups background usage by source, with manual refresh and updates when usage is diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index ed0e433c..2845674f 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -119,6 +119,7 @@ ### 工作管理、长期协作与工作流 - [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。独立“系统任务”页签按来源汇总后台用量,支持手动刷新和记录更新后刷新;监督回顾、图片重新生成、图谱提取和远程嵌入补充用量记录,不补算缺失的历史或供应商数据。活动按会话分组并默认收起,避免长历史占满页面。 + OpenCode 的输入量已扣除缓存,计算命中率时会加回缓存读取和写入量;查询已有记录时也按此口径重算。 - [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。 - [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。 - [x] **记忆与智能心跳**:计划触发共享增量回顾,不再单独生成心跳报告;可只更新记忆图谱,或从未决事项、分歧、修订和候选约定生成有依据的建议。接受未决事项创建暂停任务,确认约定写入长期背景;建议失败可单独重试,不撤销已完成回顾。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index ea91efdc..951168d9 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -7395,6 +7395,49 @@ describe('AssistantDatabase', () => { database.close() }) + it.each([false, true])('normalizes persisted OpenCode cache input in records and totals (system=%s)', async (system) => { + const database = await createDatabase() + try { + const taskId = randomUUID() + database.createTask({ + id: taskId, + conversationId: system ? `knowledge:${randomUUID()}` : randomUUID(), + title: 'Cache usage', + instructions: 'Cache usage', + workMode: 'ask' + }) + const calls = [ + { runtime: 'opencode', provider: 'goodbuddy-openai-chat', input: 60, cacheRead: 40, cacheWrite: 0 }, + { runtime: 'opencode', provider: 'goodbuddy-openai-responses', input: 10, cacheRead: 90, cacheWrite: 0 }, + { runtime: 'opencode', provider: 'custom', input: 0, cacheRead: 100, cacheWrite: 0 }, + { runtime: 'opencode', provider: 'goodbuddy-anthropic', input: 50, cacheRead: 30, cacheWrite: 20 }, + { runtime: 'model', provider: 'openai', input: 100, cacheRead: 40, cacheWrite: 0 }, + { runtime: 'model', provider: 'anthropic', input: 50, cacheRead: 30, cacheWrite: 20 } + ] + for (const [index, call] of calls.entries()) { + database.upsertModelUsageCall({ + requestId: taskId, callId: `call-${index}`, model: 'test-model', + output: 10, ...call + }) + } + // Reopen persisted raw usage: existing records need no data migration. + database.close() + database.initialize('C:\\Workspace') + const summary = database.getTokenUsageSummary() + expect(summary.records).toHaveLength(6) + for (const record of summary.records) { + expect(record.cacheInput).toBe(100) + } + expect(summary.totals).toMatchObject({ callCount: 6, cacheRead: 330, cacheInput: 600 }) + expect(system ? summary.systemTotals : summary.conversationTotals).toEqual(summary.totals) + expect(system ? summary.conversationTotals : summary.systemTotals).toMatchObject({ + callCount: 0, cacheRead: 0, cacheInput: 0 + }) + } finally { + database.close() + } + }) + it('persists global magic notes and AI comments without todo proposals', async () => { const database = await createDatabase() const globalNote = database.createMagicNote({ diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index c2da3d03..0db425f8 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -5918,6 +5918,13 @@ export class AssistantDatabase { } getTokenUsageSummary(): TokenUsageSummary { + // OpenCode reports uncached input for every provider, including OpenAI. + const cacheInputSql = `usage.input_tokens + CASE + WHEN usage.runtime = 'opencode' + OR LOWER(usage.provider) LIKE '%anthropic%' + THEN usage.cache_read_tokens + usage.cache_write_tokens + ELSE 0 + END` const systemSourceSql = `CASE WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat' WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision' @@ -5947,14 +5954,7 @@ export class AssistantDatabase { SUM(usage.output_tokens) AS output_tokens, SUM(usage.cache_read_tokens) AS cache_read_tokens, SUM(usage.cache_write_tokens) AS cache_write_tokens, - SUM( - usage.input_tokens + - CASE - WHEN LOWER(usage.provider) LIKE '%anthropic%' - THEN usage.cache_read_tokens + usage.cache_write_tokens - ELSE 0 - END - ) AS cache_input_tokens + SUM(${cacheInputSql}) AS cache_input_tokens FROM model_usage_calls usage JOIN tasks ON tasks.id = usage.request_id LEFT JOIN projects ON projects.id = tasks.project_id @@ -6017,17 +6017,7 @@ export class AssistantDatabase { COALESCE(SUM(usage.output_tokens), 0) AS output_tokens, COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens, COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens, - COALESCE( - SUM( - usage.input_tokens + - CASE - WHEN LOWER(usage.provider) LIKE '%anthropic%' - THEN usage.cache_read_tokens + usage.cache_write_tokens - ELSE 0 - END - ), - 0 - ) AS cache_input_tokens + COALESCE(SUM(${cacheInputSql}), 0) AS cache_input_tokens FROM model_usage_calls usage LEFT JOIN tasks ON tasks.id = usage.request_id GROUP BY is_system` diff --git a/src/renderer/src/token-usage.test.ts b/src/renderer/src/token-usage.test.ts index 470a45ed..ca6a0e86 100644 --- a/src/renderer/src/token-usage.test.ts +++ b/src/renderer/src/token-usage.test.ts @@ -125,6 +125,34 @@ describe('token usage aggregation', () => { }) }) + it.each([ + { provider: 'goodbuddy-openai-chat', input: 60, cacheRead: 40, cacheWrite: 0, rate: 0.4 }, + { provider: 'goodbuddy-openai-responses', input: 10, cacheRead: 90, cacheWrite: 0, rate: 0.9 }, + { provider: 'custom', input: 0, cacheRead: 100, cacheWrite: 0, rate: 1 }, + { provider: 'goodbuddy-anthropic', input: 50, cacheRead: 30, cacheWrite: 20, rate: 0.3 }, + { provider: 'openai', input: 100, cacheRead: 0, cacheWrite: 0, rate: 0 } + ])('uses complete OpenCode input for $provider ($rate)', ({ provider, input, cacheRead, cacheWrite, rate }) => { + const usage = makeTokenUsage() + usage.records = [{ + ...usage.records[0]!, + runtime: 'opencode', provider, input, cacheRead, cacheWrite + }] + + expect(groupTokenUsage(usage, 'model')[0]).toMatchObject({ + cacheInputTokens: 100, + cacheHitRate: rate + }) + usage.records[0]!.cacheInput = 100 + expect(groupTokenUsage(usage, 'model')[0]?.cacheHitRate).toBe(rate) + + // Mixed Runtime groups must sum complete inputs, not average percentages. + usage.records.push({ ...makeTokenUsage().records[1]!, input: 300 }) + expect(groupTokenUsage(usage, 'project')[0]).toMatchObject({ + cacheInputTokens: 400, + cacheHitRate: cacheRead / 400 + }) + }) + it('uses fallback labels when grouping metadata is unavailable', () => { const usage = makeTokenUsage() usage.records = [ diff --git a/src/renderer/src/token-usage.ts b/src/renderer/src/token-usage.ts index 6d61cd63..bec23132 100644 --- a/src/renderer/src/token-usage.ts +++ b/src/renderer/src/token-usage.ts @@ -31,7 +31,8 @@ function usesSeparatedAnthropicInput(provider: unknown): boolean { function usageNumbers( source: unknown, - provider?: unknown + provider?: unknown, + runtime?: unknown ): TokenUsageTotals { const values = source as Record const read = (preferred: string, legacy: string): number => { @@ -50,7 +51,7 @@ function usageNumbers( Number.isFinite(reportedCacheInput) ? reportedCacheInput : inputTokens + - (usesSeparatedAnthropicInput(provider) + (runtime === 'opencode' || usesSeparatedAnthropicInput(provider) ? cacheReadTokens + cacheWriteTokens : 0) @@ -172,7 +173,7 @@ export function groupTokenUsage( records.push(record) groupedRecords.set(identity.key, records) } - const usage = usageNumbers(record, record.provider) + const usage = usageNumbers(record, record.provider, record.runtime) const existing = rows.get(identity.key) if (existing) {