feat: release 2.2.0 with PC-only tests and execution reliability fixes

Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

@@ -0,0 +1,58 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AgentStreamComponent } from './agent-stream.component';
describe('action card text selection', () => {
let component: AgentStreamComponent;
let header: HTMLDivElement;
let outside: HTMLDivElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])]
});
component = TestBed.createComponent(AgentStreamComponent).componentInstance;
header = document.createElement('div');
header.textContent = '点击元素 Projects';
header.addEventListener('click', event => component.toggleActionExpanded('action-1', event));
outside = document.createElement('div');
outside.textContent = '其他步骤';
document.body.append(header, outside);
window.getSelection()?.removeAllRanges();
});
afterEach(() => {
window.getSelection()?.removeAllRanges();
header.remove();
outside.remove();
});
function selectText(element: HTMLElement): void {
const range = document.createRange();
range.selectNodeContents(element);
window.getSelection()?.addRange(range);
}
it('keeps selected header text and the expanded card in place for copying', () => {
component.toggleActionExpanded('action-1');
selectText(header);
header.click();
expect(component.isActionExpanded('action-1')).toBeTrue();
expect(window.getSelection()?.toString()).toBe('点击元素 Projects');
});
it('expands and collapses normally without selected text', () => {
header.click();
expect(component.isActionExpanded('action-1')).toBeTrue();
header.click();
expect(component.isActionExpanded('action-1')).toBeFalse();
});
it('allows another card to open while unrelated text is selected', () => {
selectText(outside);
header.click();
expect(component.isActionExpanded('action-1')).toBeTrue();
});
});
@@ -747,9 +747,21 @@ limitations under the License.
</div>
}
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item">
<div class="grid-item" [class.full-width]="param.key === 'Result'">
<span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span>
@if (param.key === 'Result') {
<div class="action-result-preview">{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}</div>
<details class="action-result-detail">
<summary>
<span class="result-expand-label">展开完整结果</span>
<span class="result-collapse-label">收起结果</span>
<span class="result-length">{{ param.value.length }} 字符</span>
</summary>
<pre tabindex="0" aria-label="完整操作结果,可选择复制">{{ param.value }}</pre>
</details>
} @else {
<span class="grid-value code-pill">{{ param.value }}</span>
}
</div>
}
<!-- Fallback item if all detail fields are empty -->
@@ -1851,8 +1851,8 @@ $stream-row-height: 25px;
gap: 8px;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
cursor: pointer;
user-select: none;
cursor: auto;
user-select: text;
&.plugin-tool-card.is-expanded {
width: min(100%, 760px);
@@ -2018,6 +2018,7 @@ $stream-row-height: 25px;
.action-card-header {
display: flex;
cursor: pointer;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--app-border, #f1f5f9);
@@ -4052,6 +4053,40 @@ $stream-row-height: 25px;
&:hover { background: var(--app-surface-soft, #f1f5f9); }
&:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
}
.action-result-preview {
max-height: 4.8em;
overflow: hidden;
overflow-wrap: anywhere;
white-space: pre-wrap;
font: 12px/1.6 Consolas, monospace;
color: var(--app-text-secondary, #475569);
}
.action-result-detail {
min-width: 0;
margin-top: 6px;
border: 1px solid var(--app-border, #e2e8f0);
border-radius: 6px;
color: var(--app-text-control, #334155);
background: var(--app-surface-soft, #f8fafc);
summary { padding: 8px 10px; cursor: pointer; font-size: 12px; }
.result-length { margin-left: 8px; color: var(--app-text-muted, #64748b); font-size: 11px; }
.result-collapse-label { display: none; }
&[open] .result-expand-label { display: none; }
&[open] .result-collapse-label { display: inline; }
summary:focus-visible, pre:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
pre {
max-height: 320px;
overflow: auto;
margin: 0;
padding: 10px 12px;
border-top: 1px solid var(--app-border, #e2e8f0);
font: 12px/1.6 Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
cursor: text;
}
}
:host ::ng-deep .report-markdown-body {
display: block;
width: 100%;
@@ -1454,6 +1454,11 @@ export class AgentStreamComponent implements AfterViewInit {
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
if (event) {
event.stopPropagation();
const selection = window.getSelection();
if (selection && !selection.isCollapsed && event.currentTarget instanceof Node
&& selection.containsNode(event.currentTarget, true)) {
return;
}
}
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
if (isCurrentlyExpanded) {
@@ -5,7 +5,7 @@
</button>
@if (open()) {
<section class="plugin-options" aria-label="扩展插件设置">
<p>照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。</p>
<p>{{ target() === 'computer' ? '仅在电脑端执行。请启用并授权提供电脑画面观察的插件,例如 CUA。' : '照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。' }}</p>
<label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label>
@if (loading()) { <p role="status">正在读取插件…</p> }
@else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> }
@@ -21,9 +21,9 @@
}
</div>
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
<p class="hint">{{ target() === 'computer' ? '仅 PC 任务需要选择至少一个提供电脑画面观察的插件。' : '不勾选任何插件时,本次任务只使用手机能力。' }}</p>
} @else {
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : target() === 'computer' ? '还没有可用电脑插件,请先安装、授权并启用。' : '还没有可用插件,手机测试不受影响。' }}</p>
}
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
</section>
@@ -75,4 +75,23 @@ describe('TaskPluginsComponent', () => {
expect(fixture.nativeElement.textContent).toContain('重试');
expect(fixture.componentInstance.automatic).toBeTrue();
});
it('filters PC-compatible plugins and removes incompatible manual choices on target change', () => {
const fixture = TestBed.createComponent(TaskPluginsComponent), component=fixture.componentInstance;
component.toggle();
const profile={name:'default',label:'默认',authorized:true,is_default:true};
const plugin=(id:string,targets:string[])=>({enabled:true,manifest:{id,name:id,targets},profiles:[profile]});
http.expectOne('/api/plugins').flush({plugins:[plugin('android-only',['android']),plugin('mixed-only',['mixed']),plugin('desktop',['computer'])]});
component.automatic=false;component.selected={'android-only':'default'};
const emitted=jasmine.createSpy('changed');component.changed.subscribe(emitted);
fixture.componentRef.setInput('target','computer');fixture.detectChanges();
expect(component.available().map(p=>p.manifest.id)).toEqual(['desktop']);
expect(emitted).toHaveBeenCalledWith({plugins:[]});
expect(fixture.nativeElement.textContent).toContain('画面观察');
const choice:HTMLInputElement=fixture.nativeElement.querySelector('.choice input');choice.click();fixture.detectChanges();
expect(emitted).toHaveBeenCalledWith({plugins:[{id:'desktop',profile:'default'}]});
fixture.componentRef.setInput('target','android');fixture.detectChanges();
expect(component.available().map(p=>p.manifest.id)).toEqual(['android-only','mixed-only']);
expect(component.selected).toEqual({});
});
});
@@ -1,4 +1,4 @@
import { Component, inject, output, signal } from '@angular/core';
import { Component, inject, input, OnChanges, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
@@ -11,7 +11,8 @@ export interface TaskPluginOptions {
selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink],
templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss'
})
export class TaskPluginsComponent {
export class TaskPluginsComponent implements OnChanges {
target = input<'android' | 'computer'>('android');
changed = output<TaskPluginOptions>();
private api = inject(PluginService);
automatic = true;
@@ -26,7 +27,12 @@ export class TaskPluginsComponent {
}
available() {
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
}
ngOnChanges() {
const allowed = new Set(this.available().map(p => p.manifest.id));
this.selected = Object.fromEntries(Object.entries(this.selected).filter(([id]) => allowed.has(id)));
this.emit();
}
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
toggle() {
@@ -33,7 +33,7 @@
<section class="hero-section">
<h1 class="hero-title">
你想在手机上完成什么任务?
你想在{{ pcOnly() ? '电脑' : '手机' }}上完成什么任务?
</h1>
</section>
@@ -1367,9 +1367,9 @@
<!-- Prerequisites Drop Alert if any blocker probe is not passing -->
@if (hasReadinessReport() && !isReady()) {
<div class="launcher-warning-bar" (click)="setTab('diagnostics')">
<span class="material-symbols-outlined warn-icon">{{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
<span class="material-symbols-outlined warn-icon">{{ !pcOnly() && adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
<span>
{{ adbProbe()?.summary === 'Device Locked'
{{ pcOnly() ? '运行环境或模型凭据尚未就绪。点击此处打开系统设置。' : adbProbe()?.summary === 'Device Locked'
? 'Android 设备已锁定,请先解锁并进入主屏幕。'
: '运行环境、模型凭据或 Android 设备尚未就绪。点击此处打开系统设置。' }}
</span>
@@ -1379,7 +1379,17 @@
<!-- Task composer -->
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
<app-task-plugins (changed)="taskPlugins.set($event)" />
<div class="composer-options">
<app-task-plugins [target]="pcOnly() ? 'computer' : 'android'" (changed)="taskPlugins.set($event)" />
<label class="pc-only-option" [class.is-selected]="pcOnly()" title="仅测试 PC 桌面端,无需手机或模拟器在线">
<input type="checkbox" [ngModel]="pcOnly()" (ngModelChange)="pcOnly.set($event)" [disabled]="isSubmitting()" [attr.aria-describedby]="pcOnly() ? 'pc-only-description' : null" />
<span class="material-symbols-outlined" aria-hidden="true">desktop_windows</span>
<span>仅 PC</span>
</label>
</div>
@if (pcOnly()) {
<p class="pc-only-hint" id="pc-only-description">通过已启用并授权的电脑插件执行,无需手机或模拟器在线。</p>
}
<!-- Smart Intent Recommendation Strip (if detected) -->
@if (showIntentSuggestion()) {
@@ -1406,7 +1416,7 @@
rows="3"
[(ngModel)]="taskGoal"
[disabled]="isSubmitting()"
placeholder="描述你希望在手机上完成的任务,例如:打开 Gmail 并查找最新邮件…"
[placeholder]="pcOnly() ? '描述你希望在电脑上完成的任务,例如:打开浏览器并检查网页…' : '描述你希望在手机上完成的任务,例如:打开 Gmail 并查找最新邮件…'"
(keydown.control.enter)="runTask()"
(keydown.meta.enter)="runTask()"
></textarea>
@@ -1431,7 +1441,7 @@
<p class="desc-line">
<span class="mode-name">Pro</span>
<span class="bullet">·</span>
<span>先规划、再检查结果,遇到操作受阻时自动恢复。支持 <strong>超过 100 步的</strong> 工作流和实时手机监控(<strong>15–40 秒</strong> / 步)。</span>
<span>先规划、再检查结果,遇到操作受阻时自动恢复。支持 <strong>超过 100 步的</strong> 工作流和实时{{ pcOnly() ? '电脑' : '手机' }}监控(<strong>15–40 秒</strong> / 步)。</span>
</p>
}
</div>
@@ -1487,7 +1497,7 @@
class="btn-run-composer"
[class.btn-run-pro]="selectedProfile() === 'pro'"
[disabled]="!taskGoal().trim() || isSubmitting() || !isReady()"
[title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? '请先解锁 Android 设备' : '请先完成系统设置') : '执行任务'"
[title]="!isReady() ? (!pcOnly() && adbProbe()?.summary === 'Device Locked' ? '请先解锁 Android 设备' : '请先完成系统设置') : '执行任务'"
(click)="runTask()"
>
@if (isSubmitting()) {
@@ -5168,7 +5168,37 @@ $text-light: #94a3b8;
}
}
.composer-card > app-task-plugins { margin-bottom: 8px; }
.composer-options {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
margin-bottom: 8px;
}
.pc-only-option {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
box-sizing: border-box;
padding: 5px 10px;
border: 1px solid var(--app-border, #e2e8f0);
border-radius: 20px;
background: var(--app-surface-soft, #f8fafc);
color: var(--app-text-muted, #64748b);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
&.is-selected, &:hover {
border-color: var(--app-primary-border, #bfdbfe);
background: var(--app-primary-soft, #eff6ff);
color: var(--app-primary-text, #2563eb);
}
&:focus-within { outline: 2px solid var(--app-primary-text, #2563eb); outline-offset: 3px; }
input { width: 14px; height: 14px; margin: 0; accent-color: var(--app-primary-text, #2563eb); }
.material-symbols-outlined { font-size: 16px; }
}
.pc-only-hint { margin: 0 0 8px; color: var(--app-text-muted, #64748b); font-size: 12px; line-height: 1.6; }
.exploration-settings {
margin:18px 0 24px; padding:18px 20px; background:var(--app-surface, #fff); border:1px solid var(--app-border, #e2e8f0); border-radius:20px; font-size:13px; color:var(--app-text-secondary, #475569);
@@ -1,5 +1,6 @@
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { Router } from '@angular/router';
import { HomeComponent } from './home.component';
import { AgentService } from '../../services/agent.service';
@@ -8,12 +9,13 @@ import { SystemService } from '../../services/system.service';
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
describe('Homepage exploration entry',()=>{
let api:any,agent:any,router:any;
let api:any,agent:any,router:any,system:any;
const config={model:'gpt-6.1-sol',reasoning_effort:'high',timeout_seconds:1800,demonstration_timeout_seconds:900,demonstration_warning_seconds:60};
beforeEach(()=>{
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:{isReady:()=>true}},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
system={isReady:signal(true),isComputerReady:signal(true)};
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:system},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
});
it('creates and starts exploration using independent settings and opens its run',fakeAsync(()=>{
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.setProfile('exploration');flushMicrotasks();home.taskGoal.set('探索流程');home.runTask();flushMicrotasks();
@@ -26,4 +28,28 @@ describe('Homepage exploration entry',()=>{
const createId=api.create.calls.mostRecent().args[3],startId=api.act.calls.mostRecent().args[3];expect(home.errorMessage()).toContain('失败');
api.act.and.resolveTo({run:{id:'run'}});home.runTask();flushMicrotasks();expect(api.create.calls.mostRecent().args[3]).toBe(createId);expect(api.act.calls.mostRecent().args[3]).toBe(startId);
}));
for (const profile of ['flash','pro','exploration'] as const) {
it(`submits a PC-only ${profile} task while Android is unavailable`,fakeAsync(()=>{
system.isReady.set(false);
const home=TestBed.runInInjectionContext(()=>new HomeComponent());
home.pcOnly.set(true);home.selectedProfile.set(profile);home.explorationConfig.set(config);
home.taskGoal.set('检查电脑浏览器');home.taskPlugins.set({plugins:[{id:'cua',profile:'default'}]});
agent.runTask.and.returnValue(of({status:'started'}));
expect(home.isReady()).toBeTrue();home.runTask();flushMicrotasks();
const options={plugins:[{id:'cua',profile:'default'}],execution_target:'computer'};
if(profile==='exploration') expect(api.create).toHaveBeenCalledWith('检查电脑浏览器',config,options,jasmine.any(String));
else expect(agent.runTask.calls.mostRecent().args[5]).toEqual(options);
expect(router.navigate).toHaveBeenCalledWith(['/workspace']);
}));
}
it('restores Android requirements when unchecked and keeps PC runtime requirements',()=>{
system.isReady.set(false);
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.pcOnly.set(true);
expect(home.isReady()).toBeTrue();home.pcOnly.set(false);
expect(home.isReady()).toBeFalse();expect(home.taskOptions().execution_target).toBeUndefined();
home.pcOnly.set(true);system.isComputerReady.set(false);home.taskGoal.set('检查桌面');home.runTask();
expect(home.isReady()).toBeFalse();expect(agent.runTask).not.toHaveBeenCalled();expect(api.create).not.toHaveBeenCalled();
});
});
@@ -324,7 +324,14 @@ export class HomeComponent implements OnInit, OnDestroy {
// Computed helper states delegating to SystemService
taskPlugins = signal<TaskPluginOptions>({});
public isReady = computed(() => this.systemService.isReady());
public pcOnly = signal(false);
public taskOptions = computed(() => ({
...this.taskPlugins(),
...(this.pcOnly() ? { execution_target: 'computer' } : {})
}));
public isReady = computed(() => this.pcOnly()
? this.systemService.isComputerReady()
: this.systemService.isReady());
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
public isLoading = computed(() => this.systemService.isLoading());
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
@@ -828,7 +835,7 @@ export class HomeComponent implements OnInit, OnDestroy {
const config = this.explorationConfig();
if (!config) { this.errorMessage.set('请先读取探索配置。'); this.isSubmitting.set(false); return; }
if (!this.explorationConfigValid()) { this.errorMessage.set('示范提前提醒须小于人工示范时限。'); this.isSubmitting.set(false); return; }
const taskConfig = {...this.taskPlugins()};
const taskConfig = {...this.taskOptions()};
const key = JSON.stringify([goal, config, taskConfig]);
const ids = this.explorationRequests.get(key) || {create: crypto.randomUUID(), start: crypto.randomUUID()};
this.explorationRequests.set(key, ids);
@@ -873,7 +880,7 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedProfile() === 'pro'
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
: undefined,
{ ...this.taskPlugins() }
{ ...this.taskOptions() }
)
.subscribe({
next: () => {
@@ -50,6 +50,20 @@ describe('SystemService readiness polling', () => {
afterEach(() => http.verify());
it('allows PC readiness without ADB, an online phone, or Android recording tools', () => {
const ready = report(50);
ready.probes = ['python_runtime','system_config','openai_api_key','android_adb','toolchain'].map(id => ({
id, category:'runtime', title:id, status: id === 'android_adb' || id === 'toolchain' ? 'fail' : 'pass',
is_blocker:true, summary:'',description:'',metadata:{installed:false},actions:[]
}));
service.readinessReport.set(ready);
expect(service.isReady()).toBeFalse();expect(service.isComputerReady()).toBeTrue();
for(const id of ['python_runtime','system_config','openai_api_key']) {
service.readinessReport.set({...ready,probes:ready.probes.map(p=>({...p,status:p.id===id?'fail':p.status}))});
expect(service.isComputerReady()).withContext(id).toBeFalse();
}
});
it('shares one HTTP request across overlapping readiness callers', () => {
let firstTimestamp = 0;
let secondTimestamp = 0;
@@ -116,6 +116,11 @@ export class SystemService {
public isReady = computed(() => {
return this.isEnvironmentReady() && this.isCredentialsReady() && this.isDeviceReady();
});
public isComputerReady = computed(() => {
return this.pythonProbe()?.status === 'pass'
&& this.configProbe()?.status === 'pass'
&& this.isCredentialsReady();
});
public blockerCount = computed(() => this.totalStepCount());
public passedBlockerCount = computed(() => this.passedStepCount());
public probes = computed(() => this.readinessReport()?.probes ?? []);