Skip to content

Commit bfcf33d

Browse files
committed
fix(stats): update MiMo pricing, remove session filters, add NDJSON cache for dashboard perf
1 parent 39a19e9 commit bfcf33d

17 files changed

Lines changed: 464 additions & 331 deletions

File tree

packages/types/src/providers/mimo.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,10 @@ export const mimoModels = {
2121
supportsImages: false, // Pro series is text-only
2222
supportsPromptCache: false,
2323
preserveReasoning: true,
24-
inputPrice: 1.0, // $1.00/1M tokens (cache miss, ≤256K)
25-
outputPrice: 3.0, // $3.00/1M tokens (≤256K)
26-
cacheReadsPrice: 0.2, // $0.20/1M tokens (cache hit, ≤256K)
24+
inputPrice: 0.435, // $0.435/1M tokens
25+
outputPrice: 0.87, // $0.87/1M tokens
26+
cacheReadsPrice: 0.0036, // $0.0036/1M tokens
2727
cacheWritesPrice: 0, // Free for limited time
28-
// MiMo charges 2x above 256K context
29-
longContextPricing: {
30-
thresholdTokens: 256_000,
31-
inputPriceMultiplier: 2,
32-
outputPriceMultiplier: 2,
33-
cacheReadsPriceMultiplier: 2,
34-
},
3528
description:
3629
"MiMo V2.5 Pro - Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.",
3730
},
@@ -41,17 +34,10 @@ export const mimoModels = {
4134
supportsImages: true, // Full-modal: text, image, audio, video input
4235
supportsPromptCache: false,
4336
preserveReasoning: true,
44-
inputPrice: 0.4, // $0.40/1M tokens (cache miss, ≤256K)
45-
outputPrice: 2.0, // $2.00/1M tokens (≤256K)
46-
cacheReadsPrice: 0.08, // $0.08/1M tokens (cache hit, ≤256K)
37+
inputPrice: 0.14, // $0.14/1M tokens
38+
outputPrice: 0.28, // $0.28/1M tokens
39+
cacheReadsPrice: 0.0028, // $0.0028/1M tokens
4740
cacheWritesPrice: 0, // Free for limited time
48-
// MiMo charges 2x above 256K context
49-
longContextPricing: {
50-
thresholdTokens: 256_000,
51-
inputPriceMultiplier: 2,
52-
outputPriceMultiplier: 2,
53-
cacheReadsPriceMultiplier: 2,
54-
},
5541
description:
5642
"MiMo V2.5 - Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.",
5743
},

src/core/task/Task.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,15 @@ import { getModelMaxOutputTokens } from "../../shared/api"
7878
import { McpHub } from "../../services/mcp/McpHub"
7979
import { McpServerManager } from "../../services/mcp/McpServerManager"
8080
import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
81-
import { UsageEventStore, UsageRecorder } from "../../services/stats"
81+
import { UsageRecorder } from "../../services/stats"
8282
import type { UsageRecordingContext } from "../../services/stats"
8383

8484
// integrations
8585
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
8686
import { findToolName } from "../../integrations/misc/export-markdown"
8787
import { RooTerminalProcess } from "../../integrations/terminal/types"
8888
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
89+
import { CommandScheduler } from "../../integrations/terminal/CommandScheduler"
8990
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
9091

9192
// utils
@@ -620,12 +621,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
620621
this.enableCheckpoints = enableCheckpoints
621622
this.checkpointTimeout = checkpointTimeout
622623

623-
// Initialize usage recorder (best-effort: failure results in null recorder)
624-
// Store initialization is deferred to first append; here we only construct the recorder.
625-
// If the store fails at runtime, UsageRecorder catches errors internally.
624+
// Initialize usage recorder (best-effort: failure results in null recorder).
625+
// Use the provider's shared UsageStatsService as the append sink so that all
626+
// in-process writes go through one store instance and its cache stays consistent.
627+
// If the service is unavailable, the recorder is disabled rather than creating
628+
// a second independent store authority.
626629
try {
627-
const store = new UsageEventStore(this.globalStoragePath)
628-
this.usageRecorder = new UsageRecorder(store)
630+
const service = provider.getUsageStatsService()
631+
if (service) {
632+
this.usageRecorder = new UsageRecorder(service)
633+
}
629634
} catch (err) {
630635
console.warn(`[Task#${this.taskId}] Failed to initialize UsageRecorder, stats will be skipped:`, err)
631636
}
@@ -2385,6 +2390,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23852390
console.error("Error removing event listeners:", error)
23862391
}
23872392

2393+
// Cancel any queued commands for this task before releasing terminals.
2394+
// This prevents queued commands from acquiring a terminal after the
2395+
// task is disposed (architect report Section 1.3, lifecycle ownership).
2396+
try {
2397+
CommandScheduler.getInstance().cancelTask(this.taskId)
2398+
} catch (error) {
2399+
console.error("Error cancelling queued commands:", error)
2400+
}
2401+
23882402
// Release any terminals associated with this task.
23892403
try {
23902404
// Release any terminals associated with this task.

src/core/task/__tests__/Task.usage-stats.spec.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ import { TelemetryService } from "@roo-code/telemetry"
1616
import { Task } from "../Task"
1717
import { ClineProvider } from "../../webview/ClineProvider"
1818
import { ContextProxy } from "../../config/ContextProxy"
19-
import { UsageRecorder } from "../../../services/stats/UsageRecorder"
19+
import { UsageRecorder, type UsageEventSink } from "../../../services/stats/UsageRecorder"
2020
import type { UsageRecordingContext } from "../../../services/stats/UsageRecorder"
21-
import { UsageEventStore } from "../../../services/stats/UsageEventStore"
2221

2322
// Mock @roo-code/core
2423
vi.mock("@roo-code/core", () => ({
@@ -281,7 +280,7 @@ describe("Usage Stats Recording", () => {
281280
const mockStore = {
282281
append: vi.fn().mockResolvedValue(true),
283282
initialize: vi.fn().mockResolvedValue(undefined),
284-
} as unknown as UsageEventStore
283+
} as unknown as UsageEventSink
285284
const recorder = new UsageRecorder(mockStore)
286285

287286
const ctx = makeRecordingContext()
@@ -303,7 +302,7 @@ describe("Usage Stats Recording", () => {
303302
const mockStore = {
304303
append: vi.fn().mockResolvedValue(true),
305304
initialize: vi.fn().mockResolvedValue(undefined),
306-
} as unknown as UsageEventStore
305+
} as unknown as UsageEventSink
307306
const recorder = new UsageRecorder(mockStore)
308307

309308
const ctx = makeRecordingContext()
@@ -326,7 +325,7 @@ describe("Usage Stats Recording", () => {
326325
const mockStore = {
327326
append: vi.fn().mockResolvedValue(true),
328327
initialize: vi.fn().mockResolvedValue(undefined),
329-
} as unknown as UsageEventStore
328+
} as unknown as UsageEventSink
330329
const recorder = new UsageRecorder(mockStore)
331330

332331
const ctx0 = makeRecordingContext({ attempt: 0 })
@@ -347,7 +346,7 @@ describe("Usage Stats Recording", () => {
347346
const mockStore = {
348347
append: vi.fn().mockRejectedValue(new Error("disk full")),
349348
initialize: vi.fn().mockResolvedValue(undefined),
350-
} as unknown as UsageEventStore
349+
} as unknown as UsageEventSink
351350
const recorder = new UsageRecorder(mockStore)
352351

353352
const ctx = makeRecordingContext()
@@ -361,7 +360,7 @@ describe("Usage Stats Recording", () => {
361360
const mockStore = {
362361
append: vi.fn().mockResolvedValue(true),
363362
initialize: vi.fn().mockResolvedValue(undefined),
364-
} as unknown as UsageEventStore
363+
} as unknown as UsageEventSink
365364
const recorder = new UsageRecorder(mockStore)
366365

367366
const ctx = makeRecordingContext({
@@ -386,7 +385,7 @@ describe("Usage Stats Recording", () => {
386385
const mockStore = {
387386
append: vi.fn().mockResolvedValue(true),
388387
initialize: vi.fn().mockResolvedValue(undefined),
389-
} as unknown as UsageEventStore
388+
} as unknown as UsageEventSink
390389
const recorder = new UsageRecorder(mockStore)
391390

392391
const ctx = makeRecordingContext({ parentTaskId: "parent-task-001" })
@@ -400,7 +399,7 @@ describe("Usage Stats Recording", () => {
400399
const mockStore = {
401400
append: vi.fn().mockResolvedValue(true),
402401
initialize: vi.fn().mockResolvedValue(undefined),
403-
} as unknown as UsageEventStore
402+
} as unknown as UsageEventSink
404403
const recorder = new UsageRecorder(mockStore)
405404

406405
const ctx = makeRecordingContext()
@@ -416,7 +415,7 @@ describe("Usage Stats Recording", () => {
416415
const mockStore = {
417416
append: vi.fn().mockResolvedValue(true),
418417
initialize: vi.fn().mockResolvedValue(undefined),
419-
} as unknown as UsageEventStore
418+
} as unknown as UsageEventSink
420419
const recorder = new UsageRecorder(mockStore)
421420

422421
const ctx = makeRecordingContext()
@@ -430,7 +429,7 @@ describe("Usage Stats Recording", () => {
430429
const mockStore = {
431430
append: vi.fn().mockResolvedValue(true),
432431
initialize: vi.fn().mockResolvedValue(undefined),
433-
} as unknown as UsageEventStore
432+
} as unknown as UsageEventSink
434433
const recorder = new UsageRecorder(mockStore)
435434

436435
const ctx = makeRecordingContext()
@@ -445,7 +444,7 @@ describe("Usage Stats Recording", () => {
445444
const mockStore = {
446445
append: vi.fn().mockResolvedValue(true),
447446
initialize: vi.fn().mockResolvedValue(undefined),
448-
} as unknown as UsageEventStore
447+
} as unknown as UsageEventSink
449448
const recorder = new UsageRecorder(mockStore)
450449

451450
const ctx = makeRecordingContext({
@@ -466,9 +465,9 @@ describe("Usage Stats Recording", () => {
466465

467466
describe("Task integration", () => {
468467
it("should construct usageRecorder as non-null when globalStoragePath is valid", () => {
469-
// The Task constructor wraps UsageEventStore/UsageRecorder initialization
470-
// in a try-catch. With a valid globalStoragePath, the recorder should be
471-
// successfully constructed (store initialization is deferred to first append).
468+
// The Task constructor injects the provider's shared UsageStatsService as the
469+
// UsageRecorder sink. With a valid globalStoragePath and service, the recorder
470+
// should be successfully constructed (store initialization is deferred to first append).
472471
const task = new Task({
473472
provider: mockProvider,
474473
apiConfiguration: mockApiConfig,
@@ -493,7 +492,7 @@ describe("Usage Stats Recording", () => {
493492
expect((task as any).usageRecorder).toBeDefined()
494493
})
495494

496-
it("should construct UsageRecorder with globalStoragePath from provider context", () => {
495+
it("should construct UsageRecorder with the provider's shared append sink", () => {
497496
const task = new Task({
498497
provider: mockProvider,
499498
apiConfiguration: mockApiConfig,
@@ -503,8 +502,8 @@ describe("Usage Stats Recording", () => {
503502

504503
const recorder = (task as any).usageRecorder
505504
expect(recorder).toBeInstanceOf(UsageRecorder)
506-
// The recorder should have a store that was constructed with the globalStoragePath
507-
expect(recorder.store).toBeDefined()
505+
// The recorder should be wired to the provider's UsageStatsService append sink.
506+
expect(recorder.sink).toBe(mockProvider.getUsageStatsService())
508507
})
509508
})
510509

@@ -515,7 +514,7 @@ describe("Usage Stats Recording", () => {
515514
const mockStore = {
516515
append: vi.fn().mockResolvedValue(true),
517516
initialize: vi.fn().mockResolvedValue(undefined),
518-
} as unknown as UsageEventStore
517+
} as unknown as UsageEventSink
519518
const recorder = new UsageRecorder(mockStore)
520519

521520
const ctx = makeRecordingContext({ taskId: "abc-123", attempt: 5 })
@@ -532,7 +531,7 @@ describe("Usage Stats Recording", () => {
532531
const mockStore = {
533532
append: vi.fn().mockResolvedValue(true),
534533
initialize: vi.fn().mockResolvedValue(undefined),
535-
} as unknown as UsageEventStore
534+
} as unknown as UsageEventSink
536535
const recorder = new UsageRecorder(mockStore)
537536

538537
const ctx = makeRecordingContext()

src/core/webview/__tests__/usageStatsMessageHandler.spec.ts

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,22 @@ const createMockProvider = (service?: Partial<UsageStatsService>): ClineProvider
9191
globalStorageUri: { fsPath: "/tmp/globalStorage" } as vscode.Uri,
9292
}
9393

94-
const mockService = service
95-
? (service as UsageStatsService)
96-
: undefined
94+
// Default getFilteredEvents falls back to an exportStats mock if provided,
95+
// so legacy tests that only supply exportStats still work. New tests should
96+
// supply getFilteredEvents directly for the optimized path.
97+
const legacyService = service ?? {}
98+
if (!legacyService.getFilteredEvents && legacyService.exportStats) {
99+
legacyService.getFilteredEvents = vi.fn(async (query: StatsQuery) => {
100+
const exportData = await legacyService.exportStats!(query, "json")
101+
return (exportData as JsonExport).events ?? []
102+
})
103+
}
104+
105+
let mockService: UsageStatsService | undefined = legacyService as UsageStatsService | undefined
106+
if (Object.keys(legacyService).length === 0) {
107+
// caller passed undefined explicitly
108+
mockService = undefined
109+
}
97110

98111
return {
99112
log: mockLog,
@@ -619,24 +632,41 @@ describe("usageStatsMessageHandler", () => {
619632
})
620633

621634
it("returns empty sessions list when no events", async () => {
622-
const exportStats = vi.fn().mockResolvedValue(mockJsonExport)
623-
const provider = createMockProvider({ exportStats })
624-
625-
const message: WebviewMessage = {
626-
type: "getDashboardSessions",
627-
requestId: "req-sessions-1",
628-
usageStatsQuery: validQuery,
629-
}
630-
631-
await handleGetDashboardSessions(provider, message)
632-
633-
expect(exportStats).toHaveBeenCalledWith(validQuery, "json")
634-
expect(provider.postMessageToWebview).toHaveBeenCalledWith({
635-
type: "dashboardSessionsResponse",
636-
requestId: "req-sessions-1",
637-
dashboardSessions: [],
635+
const getFilteredEvents = vi.fn().mockResolvedValue(mockJsonExport.events)
636+
const provider = createMockProvider({ getFilteredEvents })
637+
638+
const message: WebviewMessage = {
639+
type: "getDashboardSessions",
640+
requestId: "req-sessions-1",
641+
usageStatsQuery: validQuery,
642+
}
643+
644+
await handleGetDashboardSessions(provider, message)
645+
646+
expect(getFilteredEvents).toHaveBeenCalledWith(validQuery)
647+
expect(provider.postMessageToWebview).toHaveBeenCalledWith({
648+
type: "dashboardSessionsResponse",
649+
requestId: "req-sessions-1",
650+
dashboardSessions: [],
651+
})
652+
})
653+
654+
it("uses getFilteredEvents directly instead of exportStats", async () => {
655+
const getFilteredEvents = vi.fn().mockResolvedValue([])
656+
const exportStats = vi.fn()
657+
const provider = createMockProvider({ getFilteredEvents, exportStats })
658+
659+
const message: WebviewMessage = {
660+
type: "getDashboardSessions",
661+
requestId: "req-sessions-1b",
662+
usageStatsQuery: validQuery,
663+
}
664+
665+
await handleGetDashboardSessions(provider, message)
666+
667+
expect(getFilteredEvents).toHaveBeenCalledWith(validQuery)
668+
expect(exportStats).not.toHaveBeenCalled()
638669
})
639-
})
640670

641671
it("groups events by root taskId and returns summaries", async () => {
642672
const events: UsageEventV1[] = [
@@ -668,12 +698,8 @@ describe("usageStatsMessageHandler", () => {
668698
}),
669699
]
670700

671-
const exportData: JsonExport = {
672-
...mockJsonExport,
673-
events,
674-
}
675-
const exportStats = vi.fn().mockResolvedValue(exportData)
676-
const provider = createMockProvider({ exportStats })
701+
const getFilteredEvents = vi.fn().mockResolvedValue(events)
702+
const provider = createMockProvider({ getFilteredEvents })
677703

678704
const message: WebviewMessage = {
679705
type: "getDashboardSessions",

src/core/webview/usageStatsMessageHandler.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -640,11 +640,10 @@ export async function handleGetDashboardSessions(
640640

641641
const query: StatsQuery = queryResult.data
642642

643-
// Export returns the filtered raw events (JSON format) which we then
644-
// group by taskId. This reuses the service's existing time-range and
645-
// includeCancelled filtering logic without exposing a new public method.
646-
const exportData = await service.exportStats(query, "json")
647-
const events: UsageEventV1[] = (exportData as JsonExport).events ?? []
643+
// Use the cached events directly instead of export→JSON→parse. This
644+
// preserves the same time-range and includeCancelled filtering while
645+
// avoiding an unnecessary serialize/parse round-trip.
646+
const events = await service.getFilteredEvents(query)
648647

649648
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
650649

@@ -834,8 +833,8 @@ export async function handleGetDashboardSessionDetail(
834833
includeCancelled: true,
835834
}
836835

837-
const exportData = await service.exportStats(allQuery, "json")
838-
const allEvents: UsageEventV1[] = (exportData as JsonExport).events ?? []
836+
// Query all events directly to avoid the export→JSON→parse round-trip.
837+
const allEvents = await service.getFilteredEvents(allQuery)
839838

840839
// Feature 2: Filter to the requested root task AND its subtasks.
841840
// The session list groups events by root task ID, so clicking a

0 commit comments

Comments
 (0)