fix: improve cross-device evidence and exploration lifecycle

Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns.

Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
This commit is contained in:
chenmingxuan committed 2026-10-17 19:43:20 +08:00
1 parent f1bb5d02aa
commit 2328a4df0c
32 files changed
+748 -79

No files matched your search

@@ -62,6 +62,7 @@ limitations under the License.
<span>任务队列</span>
</div>
<app-task-batch-filter />
@if (deleteError()) { <p class="item-error" role="alert">{{ deleteError() }}</p> }
<div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span>
<span>当前队列 ({{ activeQueue().length }})</span>
@@ -101,7 +102,7 @@ limitations under the License.
<span class="item-time-divider">·</span>
}
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
@if (!agentService.canDeleteSession(session) && ['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button
type="button"
class="btn-stop-card"
@@ -112,7 +113,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
@if (agentService.canDeleteSession(session)) {
<button
type="button"
class="btn-delete-card"
@@ -294,6 +294,7 @@ export class AgentStreamComponent implements AfterViewInit {
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false);
public deleteError = signal('');
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with.
@@ -997,12 +998,13 @@ export class AgentStreamComponent implements AfterViewInit {
public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) {
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return;
}
this.deleteError.set('');
this.agentService.deleteSession(sessionId).subscribe({
error: (err) => {
console.error(`删除会话失败:${sessionId}:`, err);
this.deleteError.set(err.error?.detail || "删除失败,请稍后重试");
}
});
}
@@ -59,6 +59,7 @@ limitations under the License.
<!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') {
<app-task-batch-filter />
@if (errorMessage()) { <p class="error-message" role="alert">{{ errorMessage() }}</p> }
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
@@ -99,7 +100,7 @@ limitations under the License.
<span class="task-time-divider">·</span>
}
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
@if (!agentService.canDeleteSession(session) && (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused')) {
<button
type="button"
class="btn-stop-card"
@@ -110,7 +111,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
@if (agentService.canDeleteSession(session)) {
<button
type="button"
class="btn-delete-card"
@@ -162,7 +162,7 @@ export class ChatInterfaceComponent {
*/
public deleteTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) {
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return;
}
this.isSubmitting.set(true);
@@ -1,4 +1,5 @@
import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { Clipboard } from '@angular/cdk/clipboard';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
@@ -10,11 +11,46 @@ describe('历史任务检索', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.delete-history');
expect(button).not.toBeNull();
const confirm = spyOn(window, 'confirm').and.returnValue(false);
button.click();
expect(TestBed.inject(AgentService).deleteSession).not.toHaveBeenCalled();
expect(confirm.calls.mostRecent().args[0]).toContain('复跑与批次保留');
confirm.and.returnValue(true);
button.click();
expect(TestBed.inject(AgentService).deleteSession).toHaveBeenCalledOnceWith('stopped');
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.delete-history')).toBeNull();
fixture.destroy();
});
it('服务端拒绝删除时保留记录并显示具体原因', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[
{session_id:'stopped',status:'cancelled',exploration_kind:'exploration',start_time:100}
]});
spyOn(window, 'confirm').and.returnValue(true);
(TestBed.inject(AgentService).deleteSession as jasmine.Spy).and.returnValue(throwError(() => ({error:{detail:'探索正在分析,请稍后删除'}})));
fixture.detectChanges();
fixture.nativeElement.querySelector('.delete-history').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('探索正在分析');
expect(fixture.nativeElement.querySelector('.delete-history')).not.toBeNull();
fixture.destroy();
});
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
@@ -14,7 +14,7 @@ import { AgentService } from '../../services/agent.service';
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(row.exploration_kind !== "exploration"){<button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
@@ -38,7 +38,7 @@ export class TaskHistoryComponent {
});
const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
remove(id:string, kind?:string){if(!confirm(this.agent.deletionPrompt(id,kind)))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:e=>this.error.set(e.error?.detail || "删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}