feat: add cache routing alignment and usage visibility for 2.5.0

This commit is contained in:
chenmingxuan committed 2026-11-02 17:15:59 +08:00
1 parent 7c0c2b59ca
commit 3dd96078db
15 files changed
+316 -15

No files matched your search

@@ -0,0 +1,127 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { computed, signal } from '@angular/core';
import { provideRouter } from '@angular/router';
import { OverlayContainer } from '@angular/cdk/overlay';
import { Subject } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SessionUsage } from '../../core/models/session.model';
import { AgentStreamComponent } from './agent-stream.component';
describe('execution mode cache usage', () => {
let fixture: ComponentFixture<AgentStreamComponent>;
let component: AgentStreamComponent;
let service: AgentService;
let overlay: OverlayContainer;
let requests: Subject<SessionUsage>[];
const running = signal(false);
const usage = (session_id = 'cache-session', cached_tokens = 90000): SessionUsage => ({
session_id, llm_calls: 4, prompt_tokens: 305000, completion_tokens: 2100,
total_tokens: 307100, cached_tokens, operator_context_tokens: 150000,
operator_context_window_tokens: 300000
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])]
});
service = TestBed.inject(AgentService);
running.set(false);
service.currentSessionId.set('cache-session');
spyOn(service, 'currentSession').and.callFake(computed(() => ({
session_id: service.currentSessionId() || '', initial_goal: '缓存统计测试',
start_time: 1000, end_time: 1090, status: running() ? 'running' : 'completed'
})));
service.activeModel.set({ name: 'Flash', id: 'test-model', provider: 'openai' });
requests = [];
spyOn(service, 'getSessionUsage').and.callFake(() => {
const response = new Subject<SessionUsage>();
requests.push(response);
return response.asObservable();
});
fixture = TestBed.createComponent(AgentStreamComponent);
component = fixture.componentInstance;
overlay = TestBed.inject(OverlayContainer);
fixture.detectChanges();
const trigger = fixture.nativeElement.querySelector('.model-info-chip') as HTMLButtonElement;
trigger.click();
fixture.detectChanges();
});
afterEach(() => overlay.ngOnDestroy());
const text = () => TestBed.inject(OverlayContainer).getContainerElement().textContent || '';
it('renders cumulative cache input and the token-weighted rate without adding cached input to totals', () => {
expect(text()).toContain('正在读取');
requests[0].next(usage());
fixture.detectChanges();
expect(text()).toContain('缓存命中率');
expect(text()).toContain('29.5%');
expect(text()).toContain('90k 缓存 Token');
expect(text()).toContain('307.1k');
expect(text()).not.toContain('397.1k');
requests[0].next(usage('cache-session', 0));
fixture.detectChanges();
expect(text()).toContain('0%');
expect(text()).toContain('0 缓存 Token');
});
it('shows unavailable usage and fetch errors without inventing a hit rate', () => {
requests[0].next({ ...usage(), prompt_tokens: 0, cached_tokens: 0 });
fixture.detectChanges();
const row = overlay.getContainerElement().querySelector('[title^="本任务累计缓存输入"]');
expect(row?.textContent).toContain('—');
requests[0].error(new Error('offline'));
fixture.detectChanges();
expect(text()).toContain('读取失败');
expect(text()).toContain('刷新失败');
});
it('keeps cache statistics available in Pro and exploration mode', () => {
requests[0].next(usage());
for (const name of ['Pro', '探索']) {
service.activeModel.set({ name, id: 'test-model', provider: 'openai' });
fixture.detectChanges();
expect(text()).toContain('29.5%');
expect(text()).toContain('90k 缓存 Token');
}
});
it('refreshes running usage while allowing slow reads to finish and stops polling on close', fakeAsync(() => {
running.set(true);
fixture.detectChanges();
expect(requests.length).toBe(2);
tick(6000);
expect(requests.length).toBe(2);
requests[1].next(usage());
requests[1].complete();
tick(3000);
expect(requests.length).toBe(3);
requests[2].next(usage('cache-session', 180000));
fixture.detectChanges();
expect(text()).toContain('59%');
component.isRunInfoOpen.set(false);
fixture.detectChanges();
expect(requests[2].observed).toBeFalse();
tick(3000);
expect(requests.length).toBe(3);
}));
it('cancels old reads and clears the previous task usage when switching tasks or closing', () => {
requests[0].next(usage());
service.currentSessionId.set('next-session');
fixture.detectChanges();
expect(requests[0].observed).toBeFalse();
expect(component.runUsage()).toBeNull();
expect(text()).not.toContain('90k 缓存 Token');
requests[1].error(new Error('offline'));
fixture.detectChanges();
expect(text()).toContain('读取失败');
component.isRunInfoOpen.set(false);
fixture.detectChanges();
expect(overlay.getContainerElement().querySelector('.run-info-dropdown')).toBeNull();
});
});
@@ -245,6 +245,21 @@ limitations under the License.
}
</div>
<div class="run-info-row" title="本任务累计缓存输入 ÷ 累计输入,包含所有模型调用;缓存 Token 已计入输入总量。未上报的缓存输入按 0 统计。">
<span class="run-info-label">缓存命中率</span>
@if (runInfoCachePercent() !== null) {
<span class="run-info-value">
{{ runInfoCachePercent() }}%
<span class="run-info-sub">{{ formatRunTokens(runUsage()?.cached_tokens) }} 缓存 Token</span>
</span>
} @else {
<span class="run-info-value is-muted">{{ runUsageLoading() ? '正在读取…' : runUsageError() ? '读取失败' : '—' }}</span>
}
</div>
@if (runUsageError() && runUsage()) {
<div class="run-info-note" role="status">用量刷新失败,显示上次统计。</div>
}
<div class="run-info-row is-stacked">
<div class="run-info-line">
<span class="run-info-label">上下文</span>
@@ -31,6 +31,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
@@ -41,6 +42,7 @@ import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
import {
contextPercent,
cachePercent,
contextWindow,
runMode,
formatCompactTokens,
@@ -304,6 +306,9 @@ export class AgentStreamComponent implements AfterViewInit {
// usage, executor context share and the Pro tuning the run started with.
public isRunInfoOpen = signal<boolean>(false);
public runUsage = signal<SessionUsage | null>(null);
public runUsageLoading = signal(false);
public runUsageError = signal(false);
public runInfoCachePercent = computed(() => cachePercent(this.runUsage()?.cached_tokens, this.runUsage()?.prompt_tokens));
private runInfoClock = signal<number>(Date.now());
public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel()));
@@ -611,10 +616,17 @@ export class AgentStreamComponent implements AfterViewInit {
if (untracked(() => this.runUsage())?.session_id !== sessionId) {
this.runUsage.set(null);
}
this.loadRunUsage(sessionId);
if (!running) return;
const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000);
onCleanup(() => clearInterval(usageInterval));
let request: Subscription | null = null;
const refresh = () => {
if (request && !request.closed) return;
request = this.loadRunUsage(sessionId);
};
refresh();
const usageInterval = running ? setInterval(refresh, 3000) : null;
onCleanup(() => {
if (usageInterval !== null) clearInterval(usageInterval);
request?.unsubscribe();
});
});
effect((onCleanup) => {
if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return;
@@ -923,13 +935,20 @@ export class AgentStreamComponent implements AfterViewInit {
}
}
private loadRunUsage(sessionId: string): void {
this.agentService.getSessionUsage(sessionId).subscribe({
private loadRunUsage(sessionId: string): Subscription {
this.runUsageLoading.set(true);
this.runUsageError.set(false);
return this.agentService.getSessionUsage(sessionId).subscribe({
next: (usage) => {
if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsage.set(usage);
},
error: (err) => console.error('Failed to load session usage:', err)
error: () => {
if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsageError.set(true);
}
});
}