Compare commits

...
4 Commits
31 changed files with 710 additions and 54 deletions

No files matched your search

+1
View File
@@ -155,6 +155,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。 - 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。 - 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。 - 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
- 结束任务的错误与阻塞说明默认只占一行,点击展开安全 Markdown 正文与已有依据,长正文限高滚动。摘要优先采用已保存的有效结论原因,否则取执行错误首行;不新增 AI 复盘请求,不改写原始记录。展开说明不触发任务切换,刷新保留展开状态。
## Elevation ## Elevation
+1 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.4.2**:批次任务增加人工已审标签,优化收藏、标题与状态标签布局。人工评审插件保持 1.1.0。见 [2.4.2 发行说明](docs/releases/2.4.2.md);安装修复见 [2.4.1](docs/releases/2.4.1.md)。 **2.4.4**:任务结束说明默认显示一行摘要,点击展开 Markdown 正文和已记录依据,避免失败长文本占满列表;不新增 AI 复盘请求。见 [2.4.4 发行说明](docs/releases/2.4.4.md)。人工评审插件保持 1.1.0;批次异常隔离见 [2.4.3](docs/releases/2.4.3.md),列表优化见 [2.4.2](docs/releases/2.4.2.md),安装修复见 [2.4.1](docs/releases/2.4.1.md)。
## 主要功能 ## 主要功能
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.2", "version": "2.4.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.2", "version": "2.4.4",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.4", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3", "@angular/common": "^22.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.2", "version": "2.4.4",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -129,9 +129,7 @@ limitations under the License.
<div class="item-goal" [title]="session.initial_goal || ''"> <div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }} {{ session.display_name || session.initial_goal || '未命名任务' }}
</div> </div>
@if (session.error && getTaskStatus(session) === 'failed') { <app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer"> <div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span> <span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
@@ -3694,7 +3694,6 @@ $stream-row-height: 25px;
&.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); } &.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); }
&.completed { border-left: 3.5px solid #16a34a; } &.completed { border-left: 3.5px solid #16a34a; }
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; } &.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
.item-error { color: var(--app-error-text, #b91c1c); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.item-header { .item-header {
display: flex; display: flex;
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { ExplorationMessage, Demonstration } from '../../services/exploration.service'; import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util'; import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
@@ -251,7 +252,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({ @Component({
selector: 'app-agent-stream', selector: 'app-agent-stream',
standalone: true, standalone: true,
imports: [ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent], imports: [TaskOutcomeComponent,ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html', templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss', styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,7 +17,7 @@
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div> <div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div> <div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />} @if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> } @if (r.error) { <app-task-outcome [error]="r.error" [status]="r.status" /> }
</div> </div>
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div> <div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<app-evidence-sources [value]="r.evidence_sources || null" /> <app-evidence-sources [value]="r.evidence_sources || null" />
@@ -119,7 +119,7 @@
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" /> <app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>} @if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
</div> </div>
@if(j.error){<small>{{j.error}}</small>} <app-task-outcome [error]="j.error" [result]="j.test_result" [status]="j.status" />
</div> </div>
} }
</section>} </section>}
@@ -52,7 +52,6 @@ details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:p
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;} .report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;} .report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}} .report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}}
.report-error{margin-top:11px;padding:10px 12px;background:var(--app-error-soft, #fff1f2);border-left:3px solid #e11d48;border-radius:6px;color:var(--app-error-text, #9f1239);strong{font-size:12px;}p{margin:4px 0 0;color:var(--app-error-text, #9f1239);white-space:pre-wrap;}}
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}} .report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
.report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}} .report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;} .report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;}
@@ -176,7 +176,12 @@ describe('AutomationPanelComponent batch deletion', () => {
}); });
fixture.detectChanges(); fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次'); const explanation = root.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(explanation.open).toBeFalse();
expect(explanation.querySelector('summary')?.textContent).toContain('连续 3 次');
explanation.querySelector('summary')!.click();
expect(explanation.open).toBeTrue();
expect(explanation.querySelector('.body')?.textContent).toContain('连续 3 次');
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果'); expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
expect(root.querySelector('.report-body')?.textContent).toContain('第一行'); expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
expect(root.querySelector('.report-body')?.textContent).toContain('第二行'); expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
@@ -24,7 +25,7 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent], selector: 'app-automation-panel', standalone: true, imports: [TaskOutcomeComponent,FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
@@ -18,6 +18,28 @@ describe('历史任务检索', () => {
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('历史错误独立折叠,展开不选择任务且后台刷新保留展开状态', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'blocked', name:'前置条件缺失', status:'failed', start_time:100,
error:'[web-blocked] FlashRunner failed: **目标未绑定**\n\n- 未执行删除'};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const details = host.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(details.closest('button')).toBeNull();
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
fixture.componentInstance.load(true);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[{...row}]});
fixture.detectChanges();
expect(host.querySelector('app-task-outcome details')).toBe(details);
expect(details.open).toBeTrue();
expect(details.querySelector('strong')!.textContent).toBe('目标未绑定');
fixture.destroy();
});
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => { it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100}; const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
@@ -268,7 +290,7 @@ describe('历史任务检索', () => {
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类'); expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置'); expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒'); expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时'); expect(fixture.nativeElement.querySelector('app-task-outcome summary').textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败'); expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy(); fixture.destroy();
}); });
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component'; import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
@@ -9,13 +10,13 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@Component({ @Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent], selector:'app-task-history', standalone:true, imports:[TaskOutcomeComponent,CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()"> <input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<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> <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>} @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(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"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><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></div><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>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><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></button></div><app-task-outcome [error]="row.error" [result]="row.test_result" [status]="row.status" /><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>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @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> <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>
@@ -51,7 +52,6 @@ import { AgentService } from '../../services/agent.service';
.status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)} .status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)}
.status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)} .status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)}
.status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)} .status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)}
.history-error{font-size:11px;color:var(--app-error-text, #b91c1c);line-height:1.4}
.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px} .delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px}
.delete-history .material-symbols-outlined{font-size:16px} .delete-history .material-symbols-outlined{font-size:16px}
.delete-history:hover{color:var(--app-error-text, #b91c1c)} .delete-history:hover{color:var(--app-error-text, #b91c1c)}
@@ -0,0 +1,71 @@
import { TestBed } from '@angular/core/testing';
import { TaskOutcomeComponent } from './task-outcome.component';
describe('任务结束说明', () => {
function create(status = 'failed', error = '[web-example] FlashRunner failed: **前置受阻**\n\n- 未找到目标设备\n- 未执行删除') {
const fixture = TestBed.createComponent(TaskOutcomeComponent);
fixture.componentRef.setInput('status', status);
fixture.componentRef.setInput('error', error);
fixture.detectChanges();
return fixture;
}
it('默认只显示一行摘要,展开后渲染完整 Markdown,不选择外层任务', () => {
const fixture = create();
const host = fixture.nativeElement as HTMLElement;
host.style.width = '280px';
const details = host.querySelector('details')!;
expect(details.open).toBeFalse();
expect(host.querySelector('.summary-text')!.textContent).toBe('前置受阻');
expect(host.textContent).not.toContain('FlashRunner failed');
expect(host.querySelector('button details')).toBeNull();
const select = jasmine.createSpy('外层任务选择');
host.addEventListener('click', select);
host.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(select).not.toHaveBeenCalled();
expect(host.querySelector('.body strong')!.textContent).toBe('前置受阻');
expect(host.querySelectorAll('.body li').length).toBe(2);
fixture.componentRef.setInput('error', '[web-example] FlashRunner failed: **更新说明**\n\n- 已有记录');
fixture.detectChanges();
expect(host.querySelector('details')).toBe(details);
expect(details.open).toBeTrue();
host.querySelector('summary')!.click();
expect(details.open).toBeFalse();
});
it('优先采用已有结论,同时保留不同的执行错误和依据', () => {
const fixture = create();
fixture.componentRef.setInput('result', {status:'blocked', reason:'目标设备未绑定', evidence:['截图中仅有 **添加设备**']});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.summary-text')!.textContent).toBe('目标设备未绑定');
expect(host.textContent).toContain('未执行删除');
expect(host.textContent).toContain('已记录依据');
expect(host.textContent).toContain('添加设备');
});
it('完成但结论阻塞也展示说明,运行中和无内容时不展示', () => {
const fixture = create('running');
expect(fixture.nativeElement.querySelector('details')).toBeNull();
fixture.componentRef.setInput('status', 'completed');
fixture.componentRef.setInput('error', '');
fixture.componentRef.setInput('result', {status:'blocked',reason:'缺少预置数据',evidence:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.summary-text').textContent).toBe('缺少预置数据');
fixture.componentRef.setInput('result', null);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
it('不重复相同说明,不执行错误正文中的 HTML 或危险链接', () => {
const error = '**失败**\n<script>alert(1)</script>\n[打开](javascript:alert(1))';
const fixture = create('failed', error);
fixture.componentRef.setInput('result', {status:'blocked',reason:error,evidence:[]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelectorAll('app-report-markdown').length).toBe(1);
expect(host.querySelector('script')).toBeNull();
expect(host.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { Component, computed, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
/** Read-only presentation of persisted results; opening it never requests a model. */
@Component({
selector: 'app-task-outcome',
standalone: true,
imports: [ReportMarkdownComponent],
template: `@if (visible()) {
<details (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<summary aria-label="任务说明,展开或收起">
<span class="chevron" aria-hidden="true">›</span>
<span class="summary-text">{{summary()}}</span>
<span class="hint">详情</span>
</summary>
<div class="body">
@if (reason()) { <app-report-markdown [content]="reason()" /> }
@if (message() && message() !== reason()) {
@if (reason()) { <p class="label">执行记录</p> }
<app-report-markdown [content]="message()" />
}
@if (result()?.evidence?.length) {
<p class="label">已记录依据</p>
<ul>@for (item of result()!.evidence; track $index) {<li><app-report-markdown [content]="item" /></li>}</ul>
}
</div>
</details>
}`,
styles: [`
:host { display: block; min-width: 0; max-width: 100%; }
details { margin: 4px 0; font-size: 12px; color: var(--app-text-secondary, #475569); }
summary { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 26px; cursor: pointer; list-style: none; border-radius: 4px; }
summary::-webkit-details-marker { display: none; }
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.summary-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chevron { flex: none; font-size: 18px; line-height: 20px; }
details[open] .chevron { transform: rotate(90deg); }
.hint { flex: none; font-size: 11px; color: var(--app-text-muted, #64748b); }
.body { max-height: 280px; overflow: auto; padding: 8px 10px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; background: var(--app-surface, #fff); cursor: text; }
.label { font-size: 11px; color: var(--app-text-muted, #64748b); margin: 10px 0 4px; }
ul { margin: 4px 0; padding-left: 20px; }
`]
})
export class TaskOutcomeComponent {
error = input<string | null>();
result = input<CaseTestResult | null>();
status = input('');
// Strip only the known SDK envelope, preserving the actual explanation and diagnostics.
private explanation(value: string | null | undefined): string {
return (value || '').replace(/^\[[^\]\r\n]+\]\s*FlashRunner failed:\s*/, '').trim();
}
message = computed(() => this.explanation(this.error()));
reason = computed(() => this.explanation(this.result()?.reason));
visible = computed(() => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.status())
&& !!(this.message() || (this.result()?.status !== 'passed' && this.reason())));
summary = computed(() => {
const line = (this.reason() || this.message()).split(/\r?\n/).find(part => part.trim()) || '';
return line.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s*)/, '')
.replace(/!?\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/(\*\*|__|~~|`)/g, '');
});
}
+45 -10
View File
@@ -15,6 +15,7 @@
"""UI Launch and Web Console CLI Command (artemis ui).""" """UI Launch and Web Console CLI Command (artemis ui)."""
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
@@ -34,6 +35,11 @@ import typer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _launch_text(english: str, chinese: str) -> str:
"""Honor the language selected by the Windows launch wrapper."""
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
def running_via_console_script_shim() -> bool: def running_via_console_script_shim() -> bool:
"""Return True when this CLI was launched through a console-script ``.exe`` shim. """Return True when this CLI was launched through a console-script ``.exe`` shim.
@@ -308,7 +314,12 @@ def ui_command(
) )
if restart: if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]") console.print(
_launch_text(
f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]",
f" [yellow]🔄 已请求重启,正在释放端口 {port}...[/yellow]",
)
)
load_session_reconciler() load_session_reconciler()
stop_server(port=port, timeout=12.0) stop_server(port=port, timeout=12.0)
elif is_port_in_use(port): elif is_port_in_use(port):
@@ -316,19 +327,39 @@ def ui_command(
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else "" pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty(): if sys.stdin.isatty():
console.print( console.print(
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]" _launch_text(
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]",
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。[/yellow]",
)
) )
console.print(" [dim]Choose an action:[/dim]") console.print(
console.print(" [bold]1[/bold] Open browser (default)") _launch_text(" [dim]Choose an action:[/dim]", " [dim]请选择操作:[/dim]")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)") )
console.print(" [bold]3[/bold] Cancel") console.print(
_launch_text(
" [bold]1[/bold] Open browser (default)",
" [bold]1[/bold] 打开浏览器(默认)",
)
)
console.print(
_launch_text(
" [bold]2[/bold] Restart server (stop existing and start here)",
" [bold]2[/bold] 重启服务(停止现有服务并在此启动,会中断正在运行的任务)",
)
)
console.print(_launch_text(" [bold]3[/bold] Cancel", " [bold]3[/bold] 取消"))
try: try:
choice = input(" Select [1-3, default 1]: ").strip() choice = input(
_launch_text(" Select [1-3, default 1]: ", " 请输入选项 [1-3,默认 1]:")
).strip()
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
choice = "1" choice = "1"
if choice == "2": if choice == "2":
console.print( console.print(
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]" _launch_text(
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]",
f"\n [yellow]🔄 正在停止端口 {port} 上的现有 ARTEMIS 服务...[/yellow]",
)
) )
load_session_reconciler() load_session_reconciler()
stop_server(port=port, timeout=12.0) stop_server(port=port, timeout=12.0)
@@ -340,8 +371,12 @@ def ui_command(
return return
else: else:
console.print( console.print(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. " _launch_text(
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n" f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n",
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。"
"如需重启,请运行 '[bold]artemis restart[/bold]'。[/yellow]\n",
)
) )
if open_browser: if open_browser:
webbrowser.open(local_url) webbrowser.open(local_url)
+53
View File
@@ -0,0 +1,53 @@
"""Bounded fingerprints of received argument deltas, without retaining text."""
import hashlib
PREFIX_BYTES = 4096
MAX_PREFIXES = 16
MAX_TRACKED_TOOLS = 64
class ArgumentFingerprint:
def __init__(self):
self.characters = 0
self.bytes = 0
self.deltas = 0
self.encoding_valid = True
self.digest = hashlib.sha256()
self.prefixes: list[dict] = []
def observe(self, arguments: str) -> None:
self.characters += len(arguments)
if not arguments:
return
self.deltas += 1
if not self.encoding_valid:
return
try:
encoded = arguments.encode("utf-8")
except UnicodeEncodeError:
# Diagnostics must not repair, reject or otherwise alter the stream.
self.encoding_valid = False
return
offset = 0
while offset < len(encoded) and len(self.prefixes) < MAX_PREFIXES:
size = min(PREFIX_BYTES - self.bytes % PREFIX_BYTES, len(encoded) - offset)
self.digest.update(encoded[offset : offset + size])
self.bytes += size
offset += size
if self.bytes % PREFIX_BYTES == 0:
self.prefixes.append({"bytes": self.bytes, "sha256": self.digest.hexdigest()})
if offset < len(encoded):
self.digest.update(encoded[offset:])
self.bytes += len(encoded) - offset
def snapshot(self) -> dict:
return {
"argument_chars": self.characters,
"argument_bytes": self.bytes if self.encoding_valid else None,
"argument_sha256": self.digest.hexdigest() if self.encoding_valid else None,
"argument_deltas": self.deltas,
"argument_prefixes": [dict(prefix) for prefix in self.prefixes],
"argument_encoding_valid": self.encoding_valid,
}
+35 -10
View File
@@ -13,6 +13,7 @@ from uuid import uuid4
from PIL import Image from PIL import Image
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_TRACKED_TOOLS
from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded
from artemis.llm.reliability import LLMExhaustedError from artemis.llm.reliability import LLMExhaustedError
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
@@ -107,6 +108,7 @@ class RequestAttempt:
self.clock = time.monotonic() self.clock = time.monotonic()
self.last_publish = self.clock self.last_publish = self.clock
self.tools: dict[int | str, dict] = {} self.tools: dict[int | str, dict] = {}
self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {}
self.review = ACTIVE_FINAL_REVIEW.get() self.review = ACTIVE_FINAL_REVIEW.get()
if self.review: if self.review:
self.review.request_started(request_id, attempt) self.review.request_started(request_id, attempt)
@@ -126,6 +128,9 @@ class RequestAttempt:
"usage": None, "usage": None,
"output_chunks": 0, "output_chunks": 0,
"tool_argument_chars": 0, "tool_argument_chars": 0,
"tool_observation_layer": "langchain_tool_call_chunks",
"argument_fingerprint_version": 1,
"tool_stats_truncated": False,
"tools": [], "tools": [],
"step_id": str(self.step_id) if self.step_id else None, "step_id": str(self.step_id) if self.step_id else None,
"parent_trace_id": str(self.parent_id) if self.parent_id else None, "parent_trace_id": str(self.parent_id) if self.parent_id else None,
@@ -167,9 +172,10 @@ class RequestAttempt:
self.data.update( self.data.update(
headers_at=time.time(), headers_at=time.time(),
http_status=response.status_code, http_status=response.status_code,
server_request_id=_identifier( server_request_id=_identifier(response.headers.get("x-oneapi-request-id"))
response.headers.get("x-request-id") or response.headers.get("request-id") or _identifier(response.headers.get("x-request-id"))
), or _identifier(response.headers.get("request-id")),
response_x_request_id=_identifier(response.headers.get("x-request-id")),
server_trace_id=_identifier(response.headers.get("x-trace-id")), server_trace_id=_identifier(response.headers.get("x-trace-id")),
phase="waiting_output", phase="waiting_output",
) )
@@ -196,18 +202,31 @@ class RequestAttempt:
self.data["usage"] = usage self.data["usage"] = usage
tool_chunks = getattr(chunk, "tool_call_chunks", None) or [] tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
# Responses exposes the same argument delta in content and tool_call_chunks. # Responses exposes the same argument delta in content and tool_call_chunks.
# Count only the latter; retain lengths/names, never argument bodies. # Count only the latter; retain bounded fingerprints, never argument bodies.
for tool in tool_chunks: for tool in tool_chunks:
arguments = tool.get("args")
if isinstance(arguments, str):
self.data["tool_argument_chars"] += len(arguments)
index = tool.get("index") index = tool.get("index")
key = index if index is not None else tool.get("id") or "unknown" call_id = _identifier(tool.get("id"))
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0}) key = index if index is not None else call_id or "unknown"
if key not in self.tools and len(self.tools) >= MAX_TRACKED_TOOLS:
self.data["tool_stats_truncated"] = True
continue
stats = self.tools.setdefault(key, {"name": None})
if call_id:
stats["call_id"] = call_id
if index is not None:
stats["chunk_index"] = index
name = tool.get("name") name = tool.get("name")
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name): if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
stats["name"] = name stats["name"] = name
arguments = tool.get("args") fingerprint = self.tool_fingerprints.get(key)
if fingerprint is None:
fingerprint = self.tool_fingerprints[key] = ArgumentFingerprint()
if isinstance(arguments, str): if isinstance(arguments, str):
stats["argument_chars"] += len(arguments) fingerprint.observe(arguments)
self.data["tool_argument_chars"] += len(arguments) stats.update(fingerprint.snapshot())
self.data["tools"] = list(self.tools.values()) self.data["tools"] = list(self.tools.values())
content = getattr(chunk, "content", None) content = getattr(chunk, "content", None)
substantive = isinstance(content, str) and bool(content) substantive = isinstance(content, str) and bool(content)
@@ -252,7 +271,9 @@ class RequestAttempt:
if isinstance(status, int): if isinstance(status, int):
self.data["http_status"] = status self.data["http_status"] = status
if request_id := _identifier(getattr(error, "request_id", None)): if request_id := _identifier(getattr(error, "request_id", None)):
self.data["server_request_id"] = request_id self.data["error_request_id"] = request_id
if not self.data.get("server_request_id"):
self.data["server_request_id"] = request_id
if category == "timeout": if category == "timeout":
self.data.setdefault("timeout_scope", "transport") self.data.setdefault("timeout_scope", "transport")
if response is not None: if response is not None:
@@ -301,6 +322,7 @@ async def observe_request_headers(request):
if attempt is None: if attempt is None:
return return
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"]) request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"])
routing = {} routing = {}
for name in ("thread-id", "thread_id", "session-id", "session_id"): for name in ("thread-id", "thread_id", "session-id", "session_id"):
value = request.headers.get(name) value = request.headers.get(name)
@@ -309,9 +331,12 @@ async def observe_request_headers(request):
attempt.data.update( attempt.data.update(
request_sent_at=time.time(), request_sent_at=time.time(),
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")), client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
client_attempt_id=_identifier(request.headers.get("X-Client-Attempt-Id")),
routing_header_fingerprints=routing, routing_header_fingerprints=routing,
client_request_id_matches=request.headers.get("X-Client-Request-Id") client_request_id_matches=request.headers.get("X-Client-Request-Id")
== attempt.data["request_id"], == attempt.data["request_id"],
client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id")
== attempt.data["attempt_id"],
) )
attempt.publish() attempt.publish()
+1 -1
View File
@@ -153,7 +153,7 @@ class LLMPermanentError(LLMCallError):
class LLMExhaustedError(LLMCallError): class LLMExhaustedError(LLMCallError):
"""Retryable failures exhausted every attempt and the pause deadline.""" """Retryable failures exhausted attempts and any applicable pause deadline."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -23,7 +23,7 @@ class ToolArgumentOverflow(ValueError):
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT, "limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
} }
super().__init__( super().__init__(
f"模型生成的 {name} 参数异常膨胀({characters} 字符," f"收到的 {name} 参数流超出安全限制({characters} 字符,"
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。" f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
) )
+13 -5
View File
@@ -381,7 +381,7 @@ async def _run_with_recovery[T](
immediately: retrying cannot help and pausing would hang the task. immediately: retrying cannot help and pausing would hang the task.
- When retryable attempts are exhausted: if a fallback model is waiting - When retryable attempts are exhausted: if a fallback model is waiting
(with_fallback), raise LLMExhaustedError so it takes over immediately; (with_fallback), raise LLMExhaustedError so it takes over immediately;
final acceptance also raises instead of pausing an already-finished task. final acceptance and invalid output also raise instead of globally pausing.
Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and
retry the same request on resume. retry the same request on resume.
""" """
@@ -470,7 +470,7 @@ async def _run_with_recovery[T](
) )
if failure.category is FailureCategory.INVALID_OUTPUT: if failure.category is FailureCategory.INVALID_OUTPUT:
# One fresh response can recover a generation loop. Repeated # One fresh response can recover a generation loop. Repeated
# runaway output must reach the existing fallback/pause path, # runaway output must reach fallback or fail this request,
# even when ordinary network retries are configured higher. # even when ordinary network retries are configured higher.
max_attempts = min( max_attempts = min(
max_attempts, total_attempts + max(0, policy.max_attempts - attempt) max_attempts, total_attempts + max(0, policy.max_attempts - attempt)
@@ -505,7 +505,11 @@ async def _run_with_recovery[T](
ACTIVE_ATTEMPT.reset(observation_token) ACTIVE_ATTEMPT.reset(observation_token)
# Retryable attempts exhausted. # Retryable attempts exhausted.
if _FALLBACK_AVAILABLE.get() or ACTIVE_FINAL_REVIEW.get() is not None: if (
_FALLBACK_AVAILABLE.get()
or ACTIVE_FINAL_REVIEW.get() is not None
or last_failure.category is FailureCategory.INVALID_OUTPUT
):
if failed_endpoints is not None and endpoint_key: if failed_endpoints is not None and endpoint_key:
failed_endpoints[endpoint_key] = last_error failed_endpoints[endpoint_key] = last_error
_record_llm_event( _record_llm_event(
@@ -514,7 +518,11 @@ async def _run_with_recovery[T](
"error": str(last_error)[:1000], "error": str(last_error)[:1000],
"category": last_failure.category.value, "category": last_failure.category.value,
"retryable": True, "retryable": True,
"handover": "fallback" if _FALLBACK_AVAILABLE.get() else "final_review_end", "handover": "fallback"
if _FALLBACK_AVAILABLE.get()
else "final_review_end"
if ACTIVE_FINAL_REVIEW.get() is not None
else "task_failed",
}, },
status="failed", status="failed",
) )
@@ -1253,7 +1261,7 @@ async def _with_fallback[T](
) )
# The contextvar must be reset BEFORE fallback_call runs: the fallback has # The contextvar must be reset BEFORE fallback_call runs: the fallback has
# no further fallback behind it, so its own exhaustion should pause. # no further fallback behind it, so apply its category's terminal policy.
fallback_token = _FALLBACK_AVAILABLE.set(True) fallback_token = _FALLBACK_AVAILABLE.set(True)
try: try:
result = await main_call() result = await main_call()
+39
View File
@@ -0,0 +1,39 @@
# 模型流式参数诊断
小型内置动作的参数流仍受 16384 字符保护。超限时关闭输出流、丢弃不完整参数,不执行该次动作;一次新响应重试和可用备用端点耗尽后,只结束当前任务,不进入共享的模型暂停。批次仍遵循“失败即停止”、人工暂停和取消设置。网络故障、认证错误和最终验收保留各自的恢复规则。
## 请求关联
每个模型请求尝试沿用现有 `llm_diagnostic` 记录,不另建日志数据库。
| 字段 / 请求头 | 含义 |
| --- | --- |
| `request_id` / `X-Client-Request-Id` | 同一次恢复循环内保持稳定的逻辑请求编号;切换备用调用或恢复暂停后可能建立新的逻辑请求 |
| `attempt_id` / `X-Client-Attempt-Id` | 本次客户端尝试编号;重试时变化,`previous_attempt_id` 关联前次尝试 |
| `client_*_id_matches` | 实际发送的请求头是否与本地编号一致;调用方已指定的头保留原值 |
| `server_request_id` | 首个有效的 `X-Oneapi-Request-Id`、`X-Request-Id` 或 `Request-Id`,按此前后顺序选择 |
| `response_x_request_id` | 响应中的原始 `X-Request-Id`,不推定它来自模型上游 |
| `error_request_id` | SDK 异常带回的编号,不覆盖已经从响应头读取的编号 |
| `response_id` | SDK 实际返回的 Responses 响应编号 |
编号只接收有长度限制的可打印白名单字符。请求头不控制会话亲和性,不替换 `thread-id` / `session-id`。网关自身的重试应使用独立编号或序号,不能假装是一次新的客户端尝试。
## 参数指纹 v1
`tool_observation_layer=langchain_tool_call_chunks` 明确表示客户端观测的是 SDK 转换后的参数片段,不是原始 SSE 帧。每工具记录实际可得的 `call_id`、`chunk_index`、工具名,以及:
- `argument_chars`:参数片段的 Unicode 字符数之和。
- `argument_bytes` / `argument_sha256`:按原顺序连接片段后,其 UTF-8 字节长度和 SHA256;不解析、重排参数 JSON,也不规范化空白或 Unicode。
- `argument_deltas`:非空参数片段数,不能直接与网关原始事件数比较。
- `argument_prefixes`:每 4096 字节的累计 SHA256,最多保留前 16 个。可以在分片大小不同、上游预读或中途取消时比较相同字节位置。
- `argument_encoding_valid`:遇到不能编码为 UTF-8 的片段时为 false,当前总字节数和总哈希留空,避免伪造可比结果;诊断本身不改变执行流程。
最多记录 64 个工具;超过时设置 `tool_stats_truncated=true`,总参数字符计数仍继续。诊断不保留参数正文。哈希仅证明已收到的字节前缀,不能证明输出完整或动作成功;完整性仍由响应终止状态和解析校验决定。
跨三端优先使用 `response_id + call_id` 关联。LangChain 的 `chunk_index` 可能因文本或推理子块不同于原始 `output_index`,缺失编号时不能按数组位置猜测。正常的 `arguments.done`、`output_item.done`、`response.completed` 快照不重复计入参数;自定义工具转换、非空初始参数或协议异常需要先核对 SDK 映射,不能仅凭跨层哈希不同认定某一端重复发送。
历史日志没有保存的原始帧无法由新增诊断恢复。HTTP 200 只说明响应头已到达;客户端保护后出现的 499 或 Broken pipe 也不能证明最初原因。网关写入本机下游 writer 成功不等于客户端已消费或执行。
## 离线验证
`tests/unit/test_llm_stream_guard.py` 验证保护、有限重试、相同/不同备用端点,以及不会创建或清除其他请求的全局暂停。`tests/unit/test_request_diagnostics.py` 验证请求编号、超时重试、响应头及单次参数计数。`tests/unit/test_argument_fingerprint.py` 验证不同分片、中文和 emoji、固定字节前缀、有界统计与异常编码。这些测试不调用真实模型或设备。
+25
View File
@@ -0,0 +1,25 @@
# ARTEMIS 2.4.3
本版修复异常模型输出影响整个批次的问题,并增加流式参数诊断。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
## 更新
- 已识别的异常输出(包括工具参数溢出、响应完整性校验失败)在有限重试和可用备用端点耗尽后,只结束当前任务,不再创建全局模型暂停。批次未开启“失败即停止”时可继续下一条;人工暂停、失败即停止、网络恢复及最终验收的原有规则保留。
- 保留 16384 字符工具参数保护,关闭异常输出流,丢弃未完成内容,不执行本次异常动作。报错改为描述“收到的参数流”,避免在证据不足时直接归责模型。
- 增加单次尝试请求头,区分逻辑请求、客户端尝试、网关响应与 SDK 异常中的请求编号,便于跨服务核对。
- 对收到的工具参数片段记录字符数、UTF-8 字节数及有界 SHA256 前缀摘要,默认不新增参数正文记录。最多跟踪 64 个工具,每工具最多保留 16 个固定位置前缀。SDK 的 chunk_index 与原始协议 output_index 分开命名。
诊断字段及边界见 [流式参数诊断](../llm-stream-diagnostics.md)。本次主程序安装不会部署或升级 NEWAPI、CodexManager;服务端修复独立交付。
## 验证
- 默认 Python 回归:3284 通过,6 跳过,8 按标记排除;Ruff 格式和静态检查、质量基线、核心类型检查通过。
- 五类合成输入经过实际 CodexManager、NEWAPI 转发函数及原 OpenAI/LangChain SDK 的离线文件串接:正常输入有效;超长、累计全文、重复及半关闭输入被拒绝;各层参数摘要一致,设备动作执行数为 0。
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随发布附件 `INSTALL-VERIFICATION.md` 提供。
- 未重跑用户原业务批次、未做高并发压力测试或真实模型端到端验证。历史原始参数帧未保存,不能据此认定原事故参数膨胀首先发生在哪一层。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.3-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
+29
View File
@@ -0,0 +1,29 @@
# ARTEMIS 2.4.4
本版改善任务结束后错误和阻塞说明的阅读体验,基于 2.4.3。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
## 更新
- 历史任务、任务切换列表和批次任务默认只显示一行说明,超长内容省略,点击可展开;报告异常说明采用相同折叠展示。
- 展开后安全渲染 Markdown 正文、列表和表格,并展示已有结论及已记录依据;长正文限高滚动。
- 列表摘要优先使用已保存的结论原因,否则使用执行错误首行;展示时移除 FlashRunner 固定技术前缀,原始记录保持不变。
- 仅展示已结束任务的已有信息,不新增 AI 复盘或模型请求。展开说明不触发任务切换,列表后台刷新保留展开状态。
- `start-cn.bat` 检测到正在运行的服务时,服务占用提示、打开浏览器/重启/取消菜单、输入提示及重启进度均使用中文;非交互启动提示也使用中文。默认仍只打开浏览器,选择重启才停止现有服务。
本版不改变任务执行、模型重试或批次停止策略,继续保留 2.4.3 的异常输出隔离与参数保护。
## 验证
- 前端回归:353 项通过,生产构建通过。
- 内置浏览器使用隔离样例验证默认折叠、Markdown 展开、长内容、无错误完成任务、批次布局、浅色/深色与窄视口。
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随附件 `INSTALL-VERIFICATION.md` 提供。
- 启动语言与 Windows 启动脚本定向回归 31 项通过,Ruff 格式与静态检查、质量基线及核心类型检查通过。Python 默认确定性回归结果随安装验证附件提供。
- 未复跑用户业务任务、调用真实模型或进行高并发测试。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.4-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
2026-10-29 撤回首次发布的 2.4.4,补充中文启动提示后重新发布。此前下载过 2.4.4 的用户请重新下载并核对最新校验文件,或使用更新脚本获取最新提交。首次发布包的 SHA256 为 `9a2a678c0cf97b5de140479a3f3946171d20d19efaf3ad249c708ed35a06e67a`,该包已撤回。
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务。
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.4.2" version = "2.4.4"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+59
View File
@@ -0,0 +1,59 @@
import hashlib
import json
import pytest
from langchain_core.messages import AIMessageChunk
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_PREFIXES, PREFIX_BYTES
from artemis.llm.diagnostics import RequestAttempt
@pytest.mark.parametrize("fragment_size", [1, 37, 4096, 100000])
def test_fingerprints_match_at_byte_offsets_despite_unicode_and_different_fragmentation(
fragment_size,
):
arguments = '{"target":"' + "私人内容🙂" * 8000 + '"}'
expected = arguments.encode("utf-8")
fingerprint = ArgumentFingerprint()
for offset in range(0, len(arguments), fragment_size):
fingerprint.observe(arguments[offset : offset + fragment_size])
result = fingerprint.snapshot()
assert result["argument_chars"] == len(arguments)
assert result["argument_bytes"] == len(expected)
assert result["argument_sha256"] == hashlib.sha256(expected).hexdigest()
assert result["argument_encoding_valid"] is True
assert result["argument_prefixes"] == [
{"bytes": size, "sha256": hashlib.sha256(expected[:size]).hexdigest()}
for size in range(PREFIX_BYTES, PREFIX_BYTES * MAX_PREFIXES + 1, PREFIX_BYTES)
]
assert "私人" not in json.dumps(result, ensure_ascii=False)
def test_invalid_utf8_is_marked_without_failing_or_forging_a_digest():
fingerprint = ArgumentFingerprint()
for value in ["safe", "\ud800", "later"]:
fingerprint.observe(value)
assert fingerprint.snapshot() == {
"argument_chars": 10,
"argument_bytes": None,
"argument_sha256": None,
"argument_deltas": 3,
"argument_prefixes": [],
"argument_encoding_valid": False,
}
def test_tool_tracking_is_bounded_but_total_count_keeps_all_received_arguments():
attempt = RequestAttempt(
None, request_id="bounded", attempt=1, timeout=300, max_retries=0, metadata={}
)
for index in range(65):
attempt.chunk(
AIMessageChunk(
content=[],
tool_call_chunks=[{"name": "click", "args": "{}", "index": index}],
)
)
assert len(attempt.data["tools"]) == len(attempt.tool_fingerprints) == 64
assert attempt.data["tool_stats_truncated"] is True
assert attempt.data["tool_argument_chars"] == 130
+29
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import hashlib
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -15,6 +16,7 @@ import pytest_asyncio
from artemis.llm.reliability import FailureCategory, LLMExhaustedError from artemis.llm.reliability import FailureCategory, LLMExhaustedError
from artemis.llm.response_guard import ResponseIntegrityError, ensure_response_complete from artemis.llm.response_guard import ResponseIntegrityError, ensure_response_complete
from artemis.llm.router import ModelEndpoint, ModelFactory from artemis.llm.router import ModelEndpoint, ModelFactory
from artemis.llm.diagnostics import RequestAttempt
from artemis.services import llm as llm_service from artemis.services import llm as llm_service
from artemis.services.llm import RobustChatModelWrapper, acomplete from artemis.services.llm import RobustChatModelWrapper, acomplete
@@ -195,6 +197,33 @@ async def test_completed_stream_returns_original_valid_arguments(transport_case)
assert result.tool_calls[0]["args"] == {"passed": True} assert result.tool_calls[0]["args"] == {"passed": True}
@pytest.mark.asyncio
@pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True)
async def test_real_sdk_fingerprint_counts_received_arguments_once(transport_case):
case = transport_case
arguments = json.dumps({"passed": True, "evidence": "中文🙂" * 1000}, ensure_ascii=False)
case.outputs.append(("completed", arguments))
observation = RequestAttempt(
None, request_id="sdk-test", attempt=1, timeout=300, max_retries=0, metadata={}
)
merged = None
async for chunk in case.model.astream([]):
observation.chunk(chunk)
merged = chunk if merged is None else merged + chunk
observation.finish("completed", response=merged)
assert len(observation.data["tools"]) == 1
tool = observation.data["tools"][0]
assert tool["call_id"] == "call_test"
assert tool["argument_chars"] == len(arguments)
assert tool["argument_bytes"] == len(arguments.encode())
assert tool["argument_sha256"] == hashlib.sha256(arguments.encode()).hexdigest()
assert tool["argument_prefixes"][0] == {
"bytes": 4096,
"sha256": hashlib.sha256(arguments.encode()[:4096]).hexdigest(),
}
assert tool["argument_deltas"] == 1
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True) @pytest.mark.parametrize("transport_case", ["fragmented"], indirect=True)
@pytest.mark.parametrize("status", ["completed", None]) @pytest.mark.parametrize("status", ["completed", None])
+47 -1
View File
@@ -124,7 +124,9 @@ async def test_gateway_closes_bad_stream_discards_partial_and_bounds_retry(
with pytest.raises(LLMExhaustedError) as caught: with pytest.raises(LLMExhaustedError) as caught:
await wrapper.complete([]) await wrapper.complete([])
assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT
llm._wait_for_resume.assert_awaited_once() llm._wait_for_resume.assert_not_awaited()
assert not llm.PAUSE_FILE.exists()
assert any(name == "llm_gave_up" and p["handover"] == "task_failed" for name, p in resets)
assert model.calls == 2 assert model.calls == 2
assert closed == [1, 2] assert closed == [1, 2]
failure = next( failure = next(
@@ -139,3 +141,47 @@ async def test_gateway_closes_bad_stream_discards_partial_and_bounds_retry(
assert failure["usage"] is None assert failure["usage"] is None
assert "private" not in json.dumps(records + resets, default=str) assert "private" not in json.dumps(records + resets, default=str)
assert any(name == "llm_stream_reset" and p["action"] == "discard" for name, p in resets) assert any(name == "llm_stream_reset" and p["action"] == "discard" for name, p in resets)
@pytest.mark.asyncio
@pytest.mark.parametrize("fallback_kind", ["same", "failing", "working"])
@pytest.mark.parametrize("already_paused", [False, True])
async def test_invalid_output_fallback_does_not_pause_or_clear_another_request(
fallback_kind, already_paused
):
calls = []
if already_paused:
llm.PAUSE_FILE.write_text("another request or user pause", encoding="utf-8")
async def main():
calls.append("main")
raise ToolArgumentOverflow("click", SMALL_ACTION_ARGUMENT_LIMIT + 1)
async def fallback():
calls.append("fallback")
if fallback_kind == "working":
return "valid response"
raise ToolArgumentOverflow("click", SMALL_ACTION_ARGUMENT_LIMIT + 2)
async def run():
return await llm.with_fallback(
lambda: llm._run_with_recovery(main, endpoint_key="main", max_retries=5),
lambda: llm._run_with_recovery(
fallback,
endpoint_key="main" if fallback_kind == "same" else "fallback",
max_retries=5,
),
)
if fallback_kind == "working":
assert await run() == "valid response"
else:
with pytest.raises(LLMExhaustedError) as caught:
await run()
assert caught.value.failure.category is FailureCategory.INVALID_OUTPUT
assert calls.count("main") == 2
assert calls.count("fallback") == {"same": 0, "failing": 2, "working": 1}[fallback_kind]
llm._wait_for_resume.assert_not_awaited()
assert llm.PAUSE_FILE.exists() is already_paused
if already_paused:
assert llm.PAUSE_FILE.read_text(encoding="utf-8") == "another request or user pause"
+79 -6
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
from copy import deepcopy from copy import deepcopy
import json import json
import hashlib
from types import SimpleNamespace from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
@@ -36,6 +37,46 @@ class Engine:
pass pass
@pytest.mark.parametrize(
("headers", "expected"),
[
({"X-Oneapi-Request-Id": "gateway-request"}, "gateway-request"),
(
{"X-Oneapi-Request-Id": "gateway-request", "X-Request-Id": "provider-request"},
"gateway-request",
),
({"X-Request-Id": "provider-request"}, "provider-request"),
({"Request-Id": "legacy-request"}, "legacy-request"),
(
{"X-Oneapi-Request-Id": "invalid header value", "X-Request-Id": "standard-request"},
"standard-request",
),
],
)
def test_gateway_request_id_preserves_the_standard_header_without_assuming_its_source(
headers, expected
):
attempt = RequestAttempt(
Engine(), request_id="local-request", attempt=1, timeout=300, max_retries=1, metadata={}
)
attempt.headers(httpx.Response(200, headers=headers))
assert attempt.data["server_request_id"] == expected
assert attempt.data["response_x_request_id"] == headers.get("X-Request-Id")
def test_sdk_error_cannot_overwrite_the_observed_gateway_request_id():
class RequestError(Exception):
request_id = "sdk-request"
attempt = RequestAttempt(
Engine(), request_id="local-request", attempt=1, timeout=300, max_retries=1, metadata={}
)
attempt.headers(httpx.Response(400, headers={"X-Oneapi-Request-Id": "gateway-request"}))
attempt.finish("failed", error=RequestError("failed"))
assert attempt.data["server_request_id"] == "gateway-request"
assert attempt.data["error_request_id"] == "sdk-request"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_hook_correlates_api_calls_without_logging_header_values(): async def test_request_hook_correlates_api_calls_without_logging_header_values():
engine = Engine() engine = Engine()
@@ -55,13 +96,18 @@ async def test_request_hook_correlates_api_calls_without_logging_header_values()
) )
await observe_request_headers(request) await observe_request_headers(request)
assert request.headers["x-client-request-id"] == "local-request" assert request.headers["x-client-request-id"] == "local-request"
assert request.headers["x-client-attempt-id"] == str(attempt.trace_id)
assert attempt.data["client_attempt_id_matches"] is True
assert attempt.data["client_request_id_matches"] is True assert attempt.data["client_request_id_matches"] is True
assert len(attempt.data["routing_header_fingerprints"]) == 2 assert len(attempt.data["routing_header_fingerprints"]) == 2
assert "private" not in json.dumps(engine.records, default=str) assert "private" not in json.dumps(engine.records, default=str)
request.headers["X-Client-Request-Id"] = "caller-id" request.headers["X-Client-Request-Id"] = "caller-id"
request.headers["X-Client-Attempt-Id"] = "caller-attempt"
await observe_request_headers(request) await observe_request_headers(request)
assert request.headers["x-client-request-id"] == "caller-id" assert request.headers["x-client-request-id"] == "caller-id"
assert attempt.data["client_request_id_matches"] is False assert attempt.data["client_request_id_matches"] is False
assert request.headers["X-Client-Attempt-Id"] == "caller-attempt"
assert attempt.data["client_attempt_id_matches"] is False
finally: finally:
ACTIVE_ATTEMPT.reset(token) ACTIVE_ATTEMPT.reset(token)
@@ -75,6 +121,7 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
) )
llm._ENDPOINT_BREAKER._states.clear() llm._ENDPOINT_BREAKER._states.clear()
calls = 0 calls = 0
wire_ids = []
class StalledBody(httpx.AsyncByteStream): class StalledBody(httpx.AsyncByteStream):
async def __aiter__(self): async def __aiter__(self):
@@ -84,6 +131,9 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
async def transport(request): async def transport(request):
nonlocal calls nonlocal calls
calls += 1 calls += 1
wire_ids.append(
(request.headers["X-Client-Request-Id"], request.headers["X-Client-Attempt-Id"])
)
headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"} headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"}
return httpx.Response( return httpx.Response(
200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok") 200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok")
@@ -91,7 +141,7 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
async with httpx.AsyncClient( async with httpx.AsyncClient(
transport=httpx.MockTransport(transport), transport=httpx.MockTransport(transport),
event_hooks={"response": [observe_response_headers]}, event_hooks={"request": [observe_request_headers], "response": [observe_response_headers]},
) as client: ) as client:
class Model: class Model:
@@ -123,6 +173,10 @@ async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatc
assert final["request_id"] == failed["request_id"] assert final["request_id"] == failed["request_id"]
assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2 assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2
assert final["previous_attempt_id"] == failed["attempt_id"] assert final["previous_attempt_id"] == failed["attempt_id"]
assert wire_ids == [
(failed["request_id"], failed["attempt_id"]),
(final["request_id"], final["attempt_id"]),
]
retry = next(r["payload"] for r in engine.records if r["name"] == "llm_retry") retry = next(r["payload"] for r in engine.records if r["name"] == "llm_retry")
assert retry["request_id"] == failed["request_id"] assert retry["request_id"] == failed["request_id"]
assert retry["attempt_id"] == failed["attempt_id"] assert retry["attempt_id"] == failed["attempt_id"]
@@ -163,10 +217,26 @@ def test_tool_deltas_are_counted_once_without_retaining_arguments():
attempt.finish("completed", response=combined) attempt.finish("completed", response=combined)
assert attempt.data["output_chunks"] == 4 assert attempt.data["output_chunks"] == 4
assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}') assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}')
assert attempt.data["tools"] == [ for tool, name, arguments, call_id, index, count in zip(
{"name": "click", "argument_chars": len('{"secret":"private"}')}, attempt.data["tools"],
{"name": "wait", "argument_chars": len('{"seconds":1}')}, ["click", "wait"],
] ['{"secret":"private"}', '{"seconds":1}'],
["c1", "c2"],
[0, 1],
[2, 1],
strict=True,
):
assert tool == {
"name": name,
"call_id": call_id,
"chunk_index": index,
"argument_chars": len(arguments),
"argument_bytes": len(arguments.encode()),
"argument_sha256": hashlib.sha256(arguments.encode()).hexdigest(),
"argument_deltas": count,
"argument_prefixes": [],
"argument_encoding_valid": True,
}
assert attempt.data["response_status"] == "completed" assert attempt.data["response_status"] == "completed"
assert "private" not in json.dumps(engine.records, default=str) assert "private" not in json.dumps(engine.records, default=str)
assert "secret" not in json.dumps(engine.records, default=str) assert "secret" not in json.dumps(engine.records, default=str)
@@ -205,7 +275,10 @@ async def test_timeout_during_tool_generation_keeps_last_output_and_tool_name(mo
) )
assert failed["failure_phase"] == "generating_tool_arguments" assert failed["failure_phase"] == "generating_tool_arguments"
assert failed["last_output_at"] >= failed["first_output_at"] assert failed["last_output_at"] >= failed["first_output_at"]
assert failed["tools"] == [{"name": "click", "argument_chars": 5}] assert len(failed["tools"]) == 1
tool = failed["tools"][0]
assert tool["name"] == "click" and tool["argument_chars"] == tool["argument_bytes"] == 5
assert tool["argument_sha256"] == hashlib.sha256(b'{"x":').hexdigest()
assert failed["usage"] is None assert failed["usage"] is None
assert failed["max_output_tokens"] == 12000 assert failed["max_output_tokens"] == 12000
+76
View File
@@ -109,6 +109,82 @@ def _recording_console():
return Console(file=io.StringIO(), force_terminal=False, width=120) return Console(file=io.StringIO(), force_terminal=False, width=120)
@pytest.mark.parametrize("language", ["zh-CN", "en"])
@pytest.mark.parametrize("choice", ["", "2", "3"])
def test_existing_server_prompt_respects_launch_language(monkeypatch, language, choice):
from artemis.runtime import server_lifecycle
console = _recording_console()
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", language)
monkeypatch.setattr(ui, "Console", lambda: console)
monkeypatch.setattr(ui.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port: True)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [1234])
prompts, opened, stopped = [], [], []
monkeypatch.setattr("builtins.input", lambda prompt: prompts.append(prompt) or choice)
monkeypatch.setattr(ui.webbrowser, "open", opened.append)
monkeypatch.setattr(ui, "load_session_reconciler", lambda: None)
class RestartSelected(Exception):
pass
def stop_server(**kwargs):
stopped.append(kwargs)
raise RestartSelected # Never launch or stop a real service.
monkeypatch.setattr(server_lifecycle, "stop_server", stop_server)
if choice == "2":
with pytest.raises(RestartSelected):
ui.ui_command(port=8123)
else:
ui.ui_command(port=8123)
output = console.file.getvalue()
assert "8123" in output and "PID: 1234" in output
if language == "zh-CN":
assert all(
text in output
for text in ("服务正在运行", "请选择操作", "打开浏览器(默认)", "重启服务", "取消")
)
assert "请输入选项" in prompts[0]
assert "Choose an action" not in output and "Restart server" not in output
if choice == "2":
assert "正在停止端口 8123" in output
else:
assert "Choose an action" in output and "Open browser (default)" in output
assert "Select [1-3, default 1]" in prompts[0]
assert opened == (["http://localhost:8123"] if not choice else [])
assert stopped == ([{"port": 8123, "timeout": 12.0}] if choice == "2" else [])
@pytest.mark.parametrize("language", ["zh-CN", None])
@pytest.mark.parametrize("open_browser", [True, False])
def test_existing_server_noninteractive_does_not_restart(monkeypatch, language, open_browser):
from artemis.runtime import server_lifecycle
console = _recording_console()
if language:
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", language)
else:
monkeypatch.delenv("ARTEMIS_LAUNCH_LANG", raising=False)
monkeypatch.setattr(ui, "Console", lambda: console)
monkeypatch.setattr(ui.sys.stdin, "isatty", lambda: False)
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port: True)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
monkeypatch.setattr(
server_lifecycle, "stop_server", lambda **kwargs: pytest.fail("Unexpected restart")
)
opened = []
monkeypatch.setattr(ui.webbrowser, "open", opened.append)
ui.ui_command(port=8123, open_browser=open_browser)
output = console.file.getvalue()
assert "artemis restart" in output
assert ("服务正在运行" if language else "already in use") in output
assert opened == (["http://localhost:8123"] if open_browser else [])
def test_ensure_showcase_built_skips_install_when_deps_current(monkeypatch): def test_ensure_showcase_built_skips_install_when_deps_current(monkeypatch):
steps = [] steps = []
monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True) monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True)
Generated
+1 -1
View File
@@ -248,7 +248,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.4.2" version = "2.4.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },