diff --git a/DESIGN.md b/DESIGN.md index de717ed..3127ed3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -59,7 +59,7 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产 | 层 | 职责与源码入口 | | --- | --- | | 网页、MCP、远程 SDK | 请求建模、交互与结果读取。`apps/showcase_ui`、`mcp_server`、`packages/artemis-client` 共同使用后台服务;MCP 和薄 SDK 不另启完整后台 | -| HTTP 与业务服务 | `apps/admin_console` 管理配置、统一队列、批次、收藏、历史、人工复验和导出;`TaskQueueService` 及现有资源锁负责执行准入与互斥 | +| HTTP 与业务服务 | `apps/admin_console` 管理配置、统一队列、批次、收藏、历史、人工复验和导出;2.2.0 的 PC、Android、跨端任务按提交顺序串行执行,`TaskQueueService` 与跨进程共享执行锁共同准入,旧并行参数不能绕过;暂停释放执行资源,恢复时重新排队 | | 任务与模型 | `artemis/agents`、`artemis/runtime` 执行 Flash/Pro;`artemis/exploration` 管理交流、预算、示范与用例版本;`artemis/llm`、`artemis/services/llm.py` 统一 Responses 请求 | | 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 | | 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 | @@ -96,6 +96,8 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配 普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-sol / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。 +普通 Pro 的计划全部完成后,执行循环返回既有收尾和最终验收流程,不要求额外设备动作;有新指导或验收反馈时先处理反馈,探索模式仍须交付完整报告。完成计划不等于测试通过。Operator 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。 + ## Colors 浅色使用浅灰画布、白色内容面和深色正文;暗色使用深蓝灰画布、分层内容面和浅色正文。首页主要操作使用蓝色,探索发送及相关主操作保留靛色;绿色、琥珀色、红色用于结果与需要处理的状态。新控件复用所在区域的语义色。 @@ -169,6 +171,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配 | 截图、录像与文件 | 显示真实来源和缺口;按时间定位并保留跳转前的播放/暂停状态,文件成功以真实保存回执为准 | [工作区说明](docs/automation-workspace.md) | | 保存和错误反馈 | 配置加载、保存、空值、失败与密钥已配置状态有明确提示;空密钥保留已有值,连接测试与保存分开 | [模型配置](apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts) | | Flash 判定开关 | 切换即保存,保存中禁用,失败恢复已保存值;不顺带提交其他配置草稿。说明尚未开始的任务生效、无需重启,运行中任务保持原配置 | [功能配置](apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts) | +| 仅 PC 勾选项 | 位于首页输入区,与扩展插件控件共用配色和尺寸;默认关闭。开启后切换电脑文案与插件目标,就绪条件保留运行时和模型,排除 Android 设备、锁屏、ADB 及手机录屏工具;单任务提交使用 execution_target=computer | [首页](apps/showcase_ui/src/app/pages/home/home.component.ts) | | 外观选择 | 所有页面共用,键盘可操作;窄屏可收起导航文字,但保留链接的可访问名称 | [导航](apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts) | 这些规则对应已接受的现有行为。改动后结合实际层叠样式、渲染尺寸和相关状态检查,不能只核对一处声明。 diff --git a/README.md b/README.md index f3fa694..c5e41d8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 -**2.2.0 本地开发版本,尚未发布**:新增暗色模式和更多 Markdown 格式支持,改进 Flash 设置保存、安卓输入与点击、模型响应完整性、任务进度及诊断。完整变更见 [2.2.0 说明](docs/releases/2.2.0.md)。上一发布版本及升级步骤见 [2.1.0](docs/releases/2.1.0.md);Python 3.12 启动修复见 [2.0.1](docs/releases/2.0.1.md),探索、插件和精选任务等功能见 [2.0.0](docs/releases/2.0.0.md)。 +**2.2.0**:新增无需手机在线的仅 PC 测试、全局串行队列、暗色模式、Markdown 表格及可折叠复制的工具结果;修复安卓输入清空、观测恢复、排队状态与最终验收匹配。升级内容、验证范围与配套 Cua 插件见 [2.2.0 发行说明](docs/releases/2.2.0.md)。上一版本见 [2.1.0](docs/releases/2.1.0.md);Python 3.12 启动修复见 [2.0.1](docs/releases/2.0.1.md),探索、插件和精选任务等功能见 [2.0.0](docs/releases/2.0.0.md)。 ## 主要功能 @@ -17,7 +17,7 @@ ## 安装与启动 -准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。 +Android 或跨端测试需准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器;仅 PC 测试无需手机在线,但需启用并授权电脑观察插件。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。 网页字体和 Material Symbols 图标已随源码提供,网页构建及显示无需访问 Google Fonts。 @@ -54,7 +54,7 @@ cd Artemis-Eapil 1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。 2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。 -3. 输入任务目标,选择 Flash、Pro 或探索后执行;在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。 +3. 输入任务目标,选择 Flash、Pro 或探索后执行;仅测试电脑时勾选输入区的“仅 PC”,无需手机或模拟器在线,但需已启用并授权的电脑观察插件。在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。 4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。 预设模型为 `gpt-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 diff --git a/apps/admin_console/services/task_import.py b/apps/admin_console/services/task_import.py index 59310f5..56dd194 100644 --- a/apps/admin_console/services/task_import.py +++ b/apps/admin_console/services/task_import.py @@ -51,7 +51,9 @@ class TaskOptions(BaseModel): action_failure_attempts: int | None = Field(default=None, ge=1, le=10) worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300) plugins: list[PluginSelection] | None = None - execution_target: ExecutionTarget | None = None + execution_target: ExecutionTarget | None = Field( + default=None, description="android (default), computer (PC only, no Android), or mixed" + ) @field_serializer("recording", when_used="unless-none") def serialize_recording(self, value: RecordingConfig) -> dict: @@ -117,6 +119,17 @@ def template() -> bytes: ), ] ) + help_sheet.append( + [ + "示例-PC-001", + "电脑桌面", + "浏览器检查", + "仅 PC 测试", + "已启用并授权提供画面观察的电脑插件,无需手机或模拟器在线", + "打开电脑浏览器,访问指定网页并核对实际结果。", + json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False), + ] + ) for column in range(1, 8): help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26 for row in help_sheet: diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index c6ce2b1..5b4f720 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -346,7 +346,7 @@ class TaskQueueService: @classmethod def _concurrency_limit(cls) -> int: - """Return 0 for per-device concurrency, or the global task limit.""" + """Return the shared global single-task limit, including computer targets.""" return DeviceExecutionLock.resolve_env_concurrency() @classmethod @@ -354,10 +354,8 @@ class TaskQueueService: """Persistent dispatcher scheduling pending tasks onto devices. Scans the queue and launches each eligible task as an independent - coroutine. Admission is governed by _concurrency_limit(): 0 admits one - task per device so distinct devices execute concurrently, N>=1 admits - at most N tasks across all devices. Per-device FIFO ordering and - cross-process mutual exclusion remain enforced by DeviceExecutionLock + coroutine. Admission allows one executing task across all targets. + Global FIFO ordering and cross-process mutual exclusion are enforced by DeviceExecutionLock inside each worker process. """ print( @@ -436,6 +434,8 @@ class TaskQueueService: target = cls._task_target(item) resources = set(item.get("plugin_resources", [])) if resources & busy_resources: + if limit == 1: + return continue if limit == 0: # A task without a resolved device may bind to any serial, so it @@ -451,6 +451,8 @@ class TaskQueueService: continue elif limit >= 1 and device is not None and target.lock_key in busy_devices: # A second worker for this device would wait on its lock. + if limit == 1: + return continue item["status"] = "running" diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-selection.spec.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-selection.spec.ts new file mode 100644 index 0000000..0cd9cc8 --- /dev/null +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-selection.spec.ts @@ -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(); + }); +}); diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index 32a4ead..06c48d1 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -747,9 +747,21 @@ limitations under the License. } @for (param of getActionExtraParams(item.data); track trackParam($index, param)) { -
+
{{ param.key }} - {{ param.value }} + @if (param.key === 'Result') { +
{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}
+
+ + 展开完整结果 + 收起结果 + {{ param.value.length }} 字符 + +
{{ param.value }}
+
+ } @else { + {{ param.value }} + }
} diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss index 1538009..61645ee 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss @@ -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%; diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts index 3cbbd90..e16fedf 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts @@ -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) { diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html index d861327..4da9608 100644 --- a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.html @@ -5,7 +5,7 @@ @if (open()) {
-

照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。

+

{{ target() === 'computer' ? '仅在电脑端执行。请启用并授权提供电脑画面观察的插件,例如 CUA。' : '照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。' }}

@if (loading()) {

正在读取插件…

} @else if (error()) { } @@ -21,9 +21,9 @@ }
} @empty {

还没有可用插件,请先安装、授权并启用。

} -

不勾选任何插件时,本次任务只使用手机能力。

+

{{ target() === 'computer' ? '仅 PC 任务需要选择至少一个提供电脑画面观察的插件。' : '不勾选任何插件时,本次任务只使用手机能力。' }}

} @else { -

{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}

+

{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : target() === 'computer' ? '还没有可用电脑插件,请先安装、授权并启用。' : '还没有可用插件,手机测试不受影响。' }}

} 管理插件 diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts index f4bc2c4..4253f35 100644 --- a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.spec.ts @@ -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({}); + }); }); diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts index 1655e85..43f6966 100644 --- a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts @@ -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(); 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() { diff --git a/apps/showcase_ui/src/app/pages/home/home.component.html b/apps/showcase_ui/src/app/pages/home/home.component.html index d6c62d9..37f2c5f 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.html +++ b/apps/showcase_ui/src/app/pages/home/home.component.html @@ -33,7 +33,7 @@

- 你想在手机上完成什么任务? + 你想在{{ pcOnly() ? '电脑' : '手机' }}上完成什么任务?

@@ -1367,9 +1367,9 @@ @if (hasReadinessReport() && !isReady()) {
- {{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }} + {{ !pcOnly() && adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }} - {{ adbProbe()?.summary === 'Device Locked' + {{ pcOnly() ? '运行环境或模型凭据尚未就绪。点击此处打开系统设置。' : adbProbe()?.summary === 'Device Locked' ? 'Android 设备已锁定,请先解锁并进入主屏幕。' : '运行环境、模型凭据或 Android 设备尚未就绪。点击此处打开系统设置。' }} @@ -1379,7 +1379,17 @@
- +
+ + +
+ @if (pcOnly()) { +

通过已启用并授权的电脑插件执行,无需手机或模拟器在线。

+ } @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()" > @@ -1431,7 +1441,7 @@

Pro · - 先规划、再检查结果,遇到操作受阻时自动恢复。支持 超过 100 步的 工作流和实时手机监控(15–40 秒 / 步)。 + 先规划、再检查结果,遇到操作受阻时自动恢复。支持 超过 100 步的 工作流和实时{{ pcOnly() ? '电脑' : '手机' }}监控(15–40 秒 / 步)。

}
@@ -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()) { diff --git a/apps/showcase_ui/src/app/pages/home/home.component.scss b/apps/showcase_ui/src/app/pages/home/home.component.scss index 185579f..e135742 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.scss +++ b/apps/showcase_ui/src/app/pages/home/home.component.scss @@ -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); diff --git a/apps/showcase_ui/src/app/pages/home/home.component.spec.ts b/apps/showcase_ui/src/app/pages/home/home.component.spec.ts index 8b5ae71..71bc830 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.spec.ts +++ b/apps/showcase_ui/src/app/pages/home/home.component.spec.ts @@ -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(); + }); }); diff --git a/apps/showcase_ui/src/app/pages/home/home.component.ts b/apps/showcase_ui/src/app/pages/home/home.component.ts index 36a74bd..2c4f48c 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.ts +++ b/apps/showcase_ui/src/app/pages/home/home.component.ts @@ -324,7 +324,14 @@ export class HomeComponent implements OnInit, OnDestroy { // Computed helper states delegating to SystemService taskPlugins = signal({}); - 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: () => { diff --git a/apps/showcase_ui/src/app/services/system.service.spec.ts b/apps/showcase_ui/src/app/services/system.service.spec.ts index 36fd0c5..4d6b7a2 100644 --- a/apps/showcase_ui/src/app/services/system.service.spec.ts +++ b/apps/showcase_ui/src/app/services/system.service.spec.ts @@ -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; diff --git a/apps/showcase_ui/src/app/services/system.service.ts b/apps/showcase_ui/src/app/services/system.service.ts index 365d9bd..57be02e 100644 --- a/apps/showcase_ui/src/app/services/system.service.ts +++ b/apps/showcase_ui/src/app/services/system.service.ts @@ -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 ?? []); diff --git a/artemis/agents/checker/checker.py b/artemis/agents/checker/checker.py index 3a9d632..3f284e7 100644 --- a/artemis/agents/checker/checker.py +++ b/artemis/agents/checker/checker.py @@ -84,7 +84,12 @@ class CheckVerdict(BaseModel): """One check item's verdict. The verdict value is never altered by the release decision (fail-open affects release only).""" - item_text: str = Field(description="The check line's text, quoted verbatim.") + item_text: str = Field( + description=( + "The criterion body, quoted verbatim; exclude the displayed list marker " + "and [verify]/[assert] (on_complete)/(at_end) metadata prefix." + ) + ) kind: Literal["verify", "assert"] status: Literal["passed", "failed", "inconclusive"] evidence: str = Field( @@ -470,6 +475,24 @@ def _format_check_items(check_items: list) -> str: # --- Shared tool loop ---------------------------------------------------------------- +def _canonicalize_verdict_items(verdicts: list[CheckVerdict], check_items) -> list[CheckVerdict]: + """Recognize only exact labels rendered for this review's declared checks.""" + expected = {(ci.kind, ci.text) for ci in check_items} + labels = {} + for ci in check_items: + if getattr(ci, "when", None) is None: + continue + label = _format_check_items([ci]) + labels[(ci.kind, label)] = ci.text + labels[(ci.kind, label.removeprefix("- "))] = ci.text + return [ + v.model_copy(update={"item_text": labels[(v.kind, v.item_text)]}) + if (v.kind, v.item_text) not in expected and (v.kind, v.item_text) in labels + else v + for v in verdicts + ] + + def _completed_text_report(response, check_items) -> CheckReport | None: """Reuse a complete Checker verdict, never a draft, tool receipt or partial stream.""" if not isinstance(response, AIMessage) or response.tool_calls or response.invalid_tool_calls: @@ -495,6 +518,9 @@ def _completed_text_report(response, check_items) -> CheckReport | None: # and every declared check so an incomplete answer still gets formatted. if report.test_result is None or not report.test_result.reason.strip(): return None + report = report.model_copy( + update={"verdicts": _canonicalize_verdict_items(report.verdicts, check_items)} + ) expected = {(ci.kind, ci.text) for ci in check_items} covered = {(v.kind, v.item_text) for v in report.verdicts} if not expected <= covered: @@ -532,7 +558,7 @@ def _normalize_report(report: CheckReport, check_items: list) -> CheckReport: """Node-side hygiene: vague failed verdicts downgrade to inconclusive; every expected item gets a verdict (missing ones are inconclusive).""" verdicts: list[CheckVerdict] = [] - for v in report.verdicts: + for v in _canonicalize_verdict_items(report.verdicts, check_items): if v.status == "failed" and not v.evidence.strip(): verdicts.append( CheckVerdict( diff --git a/artemis/agents/operator/operator.py b/artemis/agents/operator/operator.py index 51626e2..4afb7a3 100644 --- a/artemis/agents/operator/operator.py +++ b/artemis/agents/operator/operator.py @@ -575,6 +575,10 @@ class OperatorNode: state.execution_report = None report_requested = False other_tool_failed = False + follow_up_pending = bool( + getattr(state, "injected_instruction", None) + or getattr(state, "operator_feedback", None) + ) for iteration in range(max_iterations): control = getattr(self.ctx, "task_control", None) @@ -584,6 +588,14 @@ class OperatorNode: ): # The device may have changed while another task held its lease. return None, None, None, False + if ( + not follow_up_pending + and not other_tool_failed + and self._pro_ready_for_settlement(state) + ): + # Let the graph settle checkpoints and run final review. A + # completed ordinary plan does not need a synthetic device action. + break if ( not report_requested and not other_tool_failed @@ -707,6 +719,9 @@ class OperatorNode: current_messages.append(response) logger.info("Exploration final report prepared; returning for final review.") break + if report_text and not other_tool_failed and self._pro_ready_for_settlement(state): + current_messages.append(response) + break if getattr(self.ctx, "exploration_id", None): from artemis.exploration.runtime import publish_response @@ -751,11 +766,15 @@ class OperatorNode: "缺少证据时如实说明,不要为了结束再操作设备。" if report_requested else "No tool call was returned, so no device action occurred. " - "Use the available tool interface now, not a prose promise or JSON " - "inside text. Use an index from the current element list or " - "normalized coordinates with target_description. If blocked, " - "use an observation/diagnostic tool; do not invent a click or " - "claim success. This recovery has a limited retry budget." + "If the requested operations are already finished, read task_plan " + "and reconcile any stale pending items against the recorded evidence " + "using the note tools. Do not click, refocus, or type merely to " + "produce a tool call or finish the turn. Do not mark genuinely " + "unfinished work complete. Otherwise use the available tool " + "interface for the remaining requested work, not a prose promise " + "or JSON inside text. Use current observations for targets. If " + "blocked, use an observation/diagnostic tool; do not invent an " + "action or claim success. This recovery has a limited retry budget." ) ) ) @@ -1111,6 +1130,19 @@ class OperatorNode: logger.warning("Some action calls failed validation. Feeding back to LLM.") continue no_progress_attempts = 0 + if not other_tool_failed and any( + normalize_name(tc["name"]) in {"save_note", "update_note", "append_note"} + and tc["args"].get("key") == "task_plan" + for tc in other_calls + ): + follow_up_pending = False + if ( + not follow_up_pending + and not other_tool_failed + and self._pro_ready_for_settlement(state) + ): + # Also handle completion in the final permitted helper iteration. + break else: if no_progress_attempts: raise OperatorNoProgressError( @@ -1272,6 +1304,21 @@ class OperatorNode: "operator_tool_limit_exceeded": tool_limit_exceeded, } + def _pro_ready_for_settlement(self, state: State) -> bool: + if getattr(self.ctx, "exploration_id", None): + return False # Exploration must deliver its report before settlement. + if getattr(state, "checker_success", None) is False: + return False + control = getattr(self.ctx, "task_control", None) + if isinstance(control, TaskControl) and has_pending_instruction(control.directory): + return False + snapshot = parse_plan(self._read_task_plan_text() or "") + return bool( + snapshot.all_top_level_done + and not any(item.is_pending or item.is_active for item in snapshot.items) + and (not snapshot.continuous_top_level or getattr(state, "user_stop_requested", False)) + ) + def _exploration_ready_for_report(self, state: State) -> bool: if not getattr(self.ctx, "exploration_id", None): return False diff --git a/artemis/agents/operator/prompts.py b/artemis/agents/operator/prompts.py index a829050..411a5be 100644 --- a/artemis/agents/operator/prompts.py +++ b/artemis/agents/operator/prompts.py @@ -586,6 +586,12 @@ def render_execution_incident(incident: dict, steps: list) -> str: " appeared on top of your target (dialog, sheet, banner, keyboard, or a" " re-laid-out screen)." ) + elif category == "pixel_verification_unavailable": + lines.append( + "Evidence: target verification could not be completed; this does not show that" + " the target disappeared. Restore a working observation before locating the" + " target again. Do not infer a business application failure from this incident." + ) elif category in ("target_disappeared", "pixel_target_disappeared"): prior_str = ( f" Your last dispatched action was `{prior[0]}` (Step {prior[1]});" diff --git a/artemis/agents/validator/action_execution.py b/artemis/agents/validator/action_execution.py index 5d7ae99..f76fa93 100644 --- a/artemis/agents/validator/action_execution.py +++ b/artemis/agents/validator/action_execution.py @@ -28,7 +28,7 @@ from artemis.graph.state import State from artemis.mcp.action_names import to_canonical_call from artemis.mcp.action_session import ActionSession from artemis.utils.logger import get_logger -from artemis.utils.input_state import verify_input +from artemis.utils.input_state import expected_input_state, verify_input logger = get_logger(__name__) @@ -98,7 +98,12 @@ async def exec_action( return False, f"MCP call failed: {e}" action_item.update(execution="completed" if res.ok else "unknown", side_effect="possible") - if tool_name == "input_text" and res.ok: + expected = ( + expected_input_state(wire_args["text"], wire_args["clear_exist"]) + if tool_name == "input_text" + else None + ) + if expected is not None and res.ok: try: elements = await session.ui_hierarchy() from artemis.utils.visualization import format_minimal_list_with_elements @@ -109,10 +114,11 @@ async def exec_action( verification = "unknown" action_item["input_verification"] = verification action_item["input_verification_note"] = ( - "Value presence confirmed in fresh editable node; not business validation." - if verification == "present" - else "Input dispatched; real value is not confirmed. Observe before submitting; do not blindly retype." + f"Expected input state '{expected}' confirmed in fresh editable node; not business validation." + if verification == expected + else f"Input dispatched; expected '{expected}', observed '{verification}'. " + "Observe before submitting; do not blindly retype." ) - if verification != "present": + if verification != expected: return False, action_item["input_verification_note"] return (True, "") if res.ok else (False, res.message) diff --git a/artemis/agents/validator/categories.py b/artemis/agents/validator/categories.py index 5e8f09a..24ee741 100644 --- a/artemis/agents/validator/categories.py +++ b/artemis/agents/validator/categories.py @@ -28,6 +28,7 @@ class ValidationErrorCategory(StrEnum): TARGET_SHIFTED = "target_shifted" TARGET_OCCUPIED = "target_occupied" PIXEL_TARGET_DISAPPEARED = "pixel_target_disappeared" + PIXEL_VERIFICATION_UNAVAILABLE = "pixel_verification_unavailable" PIXEL_BYPASSED = "pixel_bypassed" XML_BYPASSED = "xml_bypassed" GENERAL = "general" diff --git a/artemis/agents/validator/precondition_pixel.py b/artemis/agents/validator/precondition_pixel.py index e96ada7..24a11ea 100644 --- a/artemis/agents/validator/precondition_pixel.py +++ b/artemis/agents/validator/precondition_pixel.py @@ -28,9 +28,11 @@ import asyncio import base64 import hashlib import math +from io import BytesIO from pathlib import Path from langchain_core.messages import HumanMessage, SystemMessage +from PIL import Image from artemis.agents.validator.categories import ValidationErrorCategory from artemis.context import ArtemisContext @@ -56,6 +58,21 @@ _MAX_ATTEMPTS = 3 _RETRY_DELAY_SECONDS = 0.3 +def _annotate_target(image_bytes, coords, coordinate_size, **kwargs) -> bytes: + """Map device pixels into each encoded screenshot before marking/cropping it.""" + if coordinate_size is not None: + width, height = coordinate_size + with Image.open(BytesIO(image_bytes)) as image: + image_width, image_height = image.size + if abs((image_width / image_height) / (width / height) - 1) > 0.01: + raise ValueError("Screenshot orientation/geometry changed; observe the target again.") + x, y = coords + if not (0 <= x < width and 0 <= y < height): + raise ValueError("Target lies outside the observed device screen.") + coords = [round(x * image_width / width), round(y * image_height / height)] + return visualization.crop_and_annotate_target(image_bytes, coords, **kwargs) + + def _strict_target(action_item: dict) -> bool: return any( action_item.get(key) @@ -71,7 +88,7 @@ def _strict_target(action_item: dict) -> bool: def _unverified(action_item: dict, message: str) -> tuple[bool, ValidationErrorCategory, str]: if _strict_target(action_item): - return False, ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, message + return False, ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE, message return True, ValidationErrorCategory.PIXEL_BYPASSED, "" @@ -182,6 +199,8 @@ async def _run_attempt( action_item: dict, state: State | None, attempt: int, + *, + coordinate_size: tuple[int, int] | None = None, ) -> tuple[bool, ValidationErrorCategory, str] | None: """Runs one pixel validation attempt. @@ -190,20 +209,18 @@ async def _run_attempt( Raises on infrastructure errors so the caller's retry loop can decide. """ # A. Take fresh live screenshot (captures state changes / dynamic loading settling) - live_screenshot_b64 = None try: live_screenshot_b64 = await session.screenshot_b64() - success_img = True - except Exception: - success_img, live_screenshot_b64 = False, None + except Exception as exc: + raise RuntimeError(f"Failed to acquire live screenshot: {exc}") from exc - if not success_img or not live_screenshot_b64: - raise Exception("Failed to acquire live screenshot from controller/MCP tool.") + if not live_screenshot_b64: + raise RuntimeError("Live screenshot was empty; target identity could not be checked.") # B. Decode and crop the live screen target live_bytes = base64.b64decode(live_screenshot_b64) - live_crop_bytes = visualization.crop_and_annotate_target( - live_bytes, current_coords, crop_size=None, dot_radius=15 + live_crop_bytes = _annotate_target( + live_bytes, current_coords, coordinate_size, crop_size=None, dot_radius=15 ) # C. Formulate multi-modal verification message @@ -308,9 +325,19 @@ async def validate_action_precondition_pixel( if not current_coords or len(current_coords) != 2: current_coords = orig_coords + device = getattr(ctx, "device", None) + dimensions = (getattr(device, "device_width", None), getattr(device, "device_height", None)) + coordinate_size = ( + dimensions if all(type(value) is int and value > 0 for value in dimensions) else None + ) + try: - neighborhood = visualization.crop_and_annotate_target( - base64.b64decode(pre_screenshot_b64), orig_coords, crop_size=256, dot_radius=0 + neighborhood = _annotate_target( + base64.b64decode(pre_screenshot_b64), + orig_coords, + coordinate_size, + crop_size=256, + dot_radius=0, ) except (ValueError, TypeError, OSError): neighborhood = pre_screenshot_b64.encode() @@ -342,8 +369,8 @@ async def validate_action_precondition_pixel( # 2. Decode and crop the static reference (original) image ONCE outside the loop to save CPU try: orig_bytes = base64.b64decode(pre_screenshot_b64) - orig_crop_bytes = visualization.crop_and_annotate_target( - orig_bytes, orig_coords, crop_size=None, dot_radius=15 + orig_crop_bytes = _annotate_target( + orig_bytes, orig_coords, coordinate_size, crop_size=None, dot_radius=15 ) except Exception as e: logger.error( @@ -378,6 +405,7 @@ async def validate_action_precondition_pixel( action_item, state, attempt, + coordinate_size=coordinate_size, ) if verdict is not None: if not verdict[0]: @@ -396,7 +424,9 @@ async def validate_action_precondition_pixel( f" error: {last_error}. Target identity remains unverified." ) return _unverified( - action_item, "Pixel verification failed; observe and locate the target again." + action_item, + f"Pixel verification unavailable after {_MAX_ATTEMPTS} attempts: {last_error}. " + "The action was not executed; restore observation before locating the target again.", ) return _unverified(action_item, "Pixel validation did not establish target identity.") diff --git a/artemis/clients/screen_client_factory.py b/artemis/clients/screen_client_factory.py index 311f262..80c56ac 100644 --- a/artemis/clients/screen_client_factory.py +++ b/artemis/clients/screen_client_factory.py @@ -45,6 +45,7 @@ from artemis.clients.accessibility_client import AccessibilityClient, HelperUnav from artemis.clients.ui_automator_client import UIAutomatorClient, UIAutomatorScreenData from artemis.config.constants import ENV_ARTEMIS_HIERARCHY_BACKEND from artemis.runtime.adb_endpoint import adb_command +from artemis.runtime.observation_budget import check_observation from artemis.runtime.helper_manager import ProvisionEvent from artemis.utils.logger import get_logger @@ -237,17 +238,22 @@ class FallbackScreenClient: self._uiautomator.stop_server() def _call(self, name: str, *args: Any, **kwargs: Any) -> Any: + check_observation() if self._helper_allowed(): self._release_uiautomation() try: result = getattr(self._helper, name)(*args, **kwargs) except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as exc: + check_observation(failure=exc) self._degrade(exc) else: + check_observation() self._set_active("helper", "helper recovered" if self._last_failure else None) self._last_failure = None return result + check_observation("UIAutomator2 观测") result = getattr(self.uiautomator, name)(*args, **kwargs) + check_observation() self._set_active("uiautomator", self._last_failure) return result @@ -304,7 +310,15 @@ class FallbackScreenClient: return self._call("send_text", text) def clear_text(self) -> bool: - return self._call("clear_text") + # Observation may change backends; a dispatched clear must never be + # repeated on another backend after an ambiguous failure. + check_observation() + backend = ( + self.uiautomator + if self._active_backend == "uiautomator" or not self._helper_allowed() + else self._helper + ) + return backend.clear_text() def press_key(self, key: str) -> Any: return self._call("press_key", key) diff --git a/artemis/clients/ui_automator_client.py b/artemis/clients/ui_automator_client.py index 209c0f1..a45241f 100644 --- a/artemis/clients/ui_automator_client.py +++ b/artemis/clients/ui_automator_client.py @@ -26,6 +26,7 @@ import base64 from io import BytesIO import re as _re import subprocess +import threading import time from typing import TYPE_CHECKING import xml.etree.ElementTree as ET @@ -36,6 +37,7 @@ import uiautomator2 as u2 from artemis.runtime.adb_endpoint import adb_command from artemis.runtime.awake_service import ensure_device_awake +from artemis.runtime.observation_budget import check_observation, current_observation from artemis.utils.logger import get_logger if TYPE_CHECKING: @@ -273,6 +275,7 @@ class _ManagedUIAutomatorDevice(u2.Device): def start_uiautomator(self) -> None: try: + check_observation() super().start_uiautomator() except BaseException: # _process is set only for a server launched by this instance; @@ -288,6 +291,17 @@ class _ManagedUIAutomatorDevice(u2.Device): ) raise + def _check_alive(self) -> bool: + # The library polls this during startup; stop retrying after timeout/cleanup. + check_observation() + return super()._check_alive() + + def _wait_ready(self, launch_timeout=30): + budget = current_observation.get() + if budget is not None: + launch_timeout = min(launch_timeout, budget.remaining()) + return super()._wait_ready(launch_timeout) + class UIAutomatorClient: """UIAutomator2 client for Android screen data retrieval. @@ -308,41 +322,80 @@ class UIAutomatorClient: self._device_id = device_id self._device: Device | None = None self._awake_strategy: str | None = None + self._connection_lock = threading.Lock() + self._state_lock = threading.RLock() + self._connection_generation = 0 def _ensure_connected(self) -> "Device": + with self._connection_lock: + return self._connect_device() + + def _connect_device(self) -> "Device": """Ensure connection to the device, handling Maestro blocker. Returns: Connected uiautomator2 Device instance """ - if self._device is not None: + with self._state_lock: + generation = self._connection_generation + device = self._device + + def check_active() -> None: + check_observation() + if generation != self._connection_generation: + raise RuntimeError("UIAutomator2 连接已取消:客户端已断开") + + check_active() + if device is not None: try: # Quick check if connection is still alive - self._device.info - return self._device + device.info + check_active() + return device except Exception: + check_active() logger.warning("UIAutomator2 connection lost, reconnecting...") self._device = None # Ensure Maestro is not blocking us _ensure_maestro_not_installed(self._device_id) + check_active() # Enroll the device in the containing Artemis service lifetime before # UIAutomator2 reads the screen. Client disconnects do not release it. if self._awake_strategy is None: - self._awake_strategy = ensure_device_awake(self._device_id) + strategy = ensure_device_awake(self._device_id) + with self._state_lock: + check_active() + self._awake_strategy = strategy # Retry connections while ADB recovers from a reboot or USB disconnect. logger.info(f"Connecting UIAutomator2 to device: {self._device_id}") last_error: Exception | None = None for attempt in range(3): + check_active() + check_observation(f"UIAutomator2 连接(第 {attempt + 1}/3 次)") if attempt: - time.sleep(1.0 * attempt) + budget = current_observation.get() + delay = 1.0 * attempt + time.sleep(min(delay, budget.remaining()) if budget else delay) + check_active() try: - self._device = _ManagedUIAutomatorDevice(self._device_id) + candidate = _ManagedUIAutomatorDevice(self._device_id) + published = False + try: + with self._state_lock: + check_active() + self._device = candidate + published = True + finally: + if not published: + self.stop_server(candidate) break except Exception as exc: last_error = exc + check_observation(failure=exc) + check_active() logger.warning( f"UIAutomator2 connect attempt {attempt + 1}/3 to" f" {self._device_id} failed: {exc}" @@ -352,8 +405,9 @@ class UIAutomatorClient: raise UIAutomatorConnectionError( self._device_id, attempt + 1, last_error ) from last_error + check_active() logger.info("UIAutomator2 connected successfully") - return self._device + return candidate def connect(self) -> None: """Connect and activate this session's awake strategy without an action.""" @@ -501,9 +555,11 @@ class UIAutomatorClient: included) for as long as it lives, so a caller that prefers the helper must release it; the pure UIAutomator2 backend keeps it for fast reconnects. """ - device = self._device - self._awake_strategy = None - self._device = None + with self._state_lock: + self._connection_generation += 1 + device = self._device + self._awake_strategy = None + self._device = None if stop_server and device is not None: self.stop_server(device) logger.info("UIAutomator2 client disconnected") diff --git a/artemis/controllers/unified_controller.py b/artemis/controllers/unified_controller.py index 301c7b1..9501467 100644 --- a/artemis/controllers/unified_controller.py +++ b/artemis/controllers/unified_controller.py @@ -239,20 +239,10 @@ class UnifiedMobileController: async def erase_text(self, nb_chars: int | None = None) -> bool: if nb_chars is not None and nb_chars > 0: for _ in range(nb_chars): - await self._driver.press_key("delete") + if not await self._driver.press_key("delete"): + return False return True - # Best-effort full clear: End -> Ctrl+A -> Delete - try: - clear_cmd = ( - "input keyevent 123 && " - "input keycombination 113 29 && input keyevent 67 && " - "input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67" - ) - await self._driver.execute_shell(clear_cmd) - except Exception: - for _ in range(30): - await self._driver.press_key("delete") - return True + return await self._driver.input_text("", clear_existing=True) async def get_ui_elements(self) -> list[dict]: screen_data = await self._driver.get_screen_data() diff --git a/artemis/drivers/android/adb_driver.py b/artemis/drivers/android/adb_driver.py index ea26f0a..3aba96b 100644 --- a/artemis/drivers/android/adb_driver.py +++ b/artemis/drivers/android/adb_driver.py @@ -18,6 +18,7 @@ import asyncio import base64 from io import BytesIO from pathlib import Path +from time import monotonic from typing import Any, Literal from PIL import Image @@ -31,6 +32,12 @@ from artemis.config.settings import settings from artemis.config.paths import get_temp_dir from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection from artemis.drivers.android.app_launch import launch_android_app +from artemis.drivers.android.input_ime import clear_focused_input +from artemis.runtime.observation_budget import ( + ObservationBudget, + check_observation, + current_observation, +) from artemis.toolchain import find_ffmpeg, find_scrcpy from artemis.utils.video import build_scrcpy_record_command from artemis.utils.ui_filter import filter_ui_hierarchy @@ -95,6 +102,8 @@ class AndroidAdbDriver(BaseDeviceDriver): self._recording_output_path: Path | None = None self._screenshot_config = load_agent_config().screenshot self._screen_capture_task: asyncio.Task[ScreenData] | None = None + self._observation_budget: ObservationBudget | None = None + self._disconnected = False @property def device_id(self) -> str: @@ -112,6 +121,7 @@ class AndroidAdbDriver(BaseDeviceDriver): async def connect(self) -> None: """Verify device availability.""" + self._disconnected = False try: state = self.device.get_state() logger.info(f"Android device '{self._device_id}' connected (state: {state})") @@ -120,6 +130,9 @@ class AndroidAdbDriver(BaseDeviceDriver): async def disconnect(self) -> None: """Clean up ongoing recordings and tunnels.""" + self._disconnected = True + if self._observation_budget is not None: + self._observation_budget.cancelled.set() if self._recording_process: await self.stop_video_recording() if self._ui_adb_client and hasattr(self._ui_adb_client, "disconnect"): @@ -127,9 +140,16 @@ class AndroidAdbDriver(BaseDeviceDriver): async def get_screen_data(self, skip_settling: bool = False) -> ScreenData: """Bound observation latency and share unfinished capture work across retries.""" + if self._disconnected: + raise RuntimeError("设备观测已断开") task = self._screen_capture_task if task is None or task.done(): - task = asyncio.create_task(self._get_screen_data(skip_settling)) + self._observation_budget = ObservationBudget( + monotonic() + settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS + ) + task = asyncio.create_task( + self._capture_with_budget(self._observation_budget, skip_settling) + ) # A timed-out caller does not cancel a blocking SDK thread or start # another concurrent read on the same device. Retrieve late errors. task.add_done_callback(lambda done: None if done.cancelled() else done.exception()) @@ -139,10 +159,22 @@ class AndroidAdbDriver(BaseDeviceDriver): asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS ) except TimeoutError as exc: + detail = self._observation_budget.detail if self._observation_budget else str(exc) raise TimeoutError( - f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)" + f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒);{detail}" ) from exc + async def _capture_with_budget( + self, budget: ObservationBudget, skip_settling: bool + ) -> ScreenData: + token = current_observation.set(budget) + try: + result = await self._get_screen_data(skip_settling) + budget.remaining() + return result + finally: + current_observation.reset(token) + async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData: """Captures screenshot and XML hierarchy concurrently.""" if not skip_settling: @@ -158,6 +190,7 @@ class AndroidAdbDriver(BaseDeviceDriver): # Check if ui_adb_client has direct screen data support if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"): try: + check_observation("屏幕辅助服务 / UIAutomator2 观测") raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data) if asyncio.iscoroutine(raw_ui_data): raw_ui_data = await raw_ui_data @@ -185,14 +218,20 @@ class AndroidAdbDriver(BaseDeviceDriver): except Exception as e: capture_errors.append(str(e)) logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}") + check_observation(failure=e) if not screenshot_base64 or not isinstance(screenshot_base64, str): try: + check_observation("ADB 截图") + budget = current_observation.get() + timeout = settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS + if budget is not None: + timeout = min(timeout, budget.remaining()) raw_image = await asyncio.to_thread( self.device.shell, ["screencap", "-p"], encoding=None, - timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, + timeout=timeout, ) pil_img = Image.open(BytesIO(raw_image)) if hasattr(pil_img, "save"): @@ -204,6 +243,7 @@ class AndroidAdbDriver(BaseDeviceDriver): except Exception as e: capture_errors.append(str(e)) logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}") + check_observation(failure=e) if not screenshot_base64 or not isinstance(screenshot_base64, str): raise RuntimeError( @@ -218,6 +258,7 @@ class AndroidAdbDriver(BaseDeviceDriver): # 2. Capture UI Hierarchy via UIAutomator if self._ui_adb_client and not ui_elements: try: + check_observation("获取界面层级") if hasattr(self._ui_adb_client, "get_hierarchy"): res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy) hierarchy = await res if asyncio.iscoroutine(res) else res @@ -351,19 +392,11 @@ class AndroidAdbDriver(BaseDeviceDriver): async def input_text(self, text: str, clear_existing: bool = True) -> bool: try: if clear_existing: - # Safe & robust clearing: Move to End -> Shift+Home selection -> Delete -> Fallback backspaces - clear_cmd = ( - "input keyevent 123 && " - "input keyevent --meta 1 122 && " - "input keyevent 67 && " - "input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67" - ) - await asyncio.to_thread( - self.device.shell, - clear_cmd, - timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, - ) - else: + if not await clear_focused_input(self._ui_adb_client): + return False + if not text: + return True + if not clear_existing: # Append mode: move cursor to the very end of existing text await asyncio.to_thread( self.device.shell, diff --git a/artemis/drivers/android/input_ime.py b/artemis/drivers/android/input_ime.py index 723a85b..38a5b72 100644 --- a/artemis/drivers/android/input_ime.py +++ b/artemis/drivers/android/input_ime.py @@ -44,6 +44,17 @@ def _escape_for_adb_text(s: str) -> str: ) +async def clear_focused_input(ui_client: Any) -> bool: + """Clear the whole editable value once; a lost reply must not replay writes.""" + clear = getattr(ui_client, "clear_text", None) + if not callable(clear): + return False + result = await asyncio.to_thread(clear) + if asyncio.iscoroutine(result): + result = await result + return result is True + + class AndroidInputIME: """Handles text input, unicode typing, and key combination injection.""" @@ -55,15 +66,11 @@ class AndroidInputIME: """Types text using adb input text, UIAutomator, or broadcast IME.""" try: if clear_existing: - # Safe & robust clearing: Move to End -> Shift+Home selection -> Delete -> Fallback backspaces - clear_cmd = ( - "input keyevent 123 && " - "input keyevent --meta 1 122 && " - "input keyevent 67 && " - "input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67" - ) - await asyncio.to_thread(self.device.shell, clear_cmd) - else: + if not await clear_focused_input(self.ui_client): + return False + if not text: + return True + if not clear_existing: # Append mode: move cursor to the end of existing text await asyncio.to_thread( self.device.shell, diff --git a/artemis/graph/graph.py b/artemis/graph/graph.py index ddf149a..67943bd 100644 --- a/artemis/graph/graph.py +++ b/artemis/graph/graph.py @@ -716,7 +716,23 @@ async def _process_plan_write( new top-level completions. ``declared_intent`` marks surgical update_note edits, which are exempt from the full-rewrite hand-slip check. """ - rejection_message = check_plan_mutation_rejections(content_before, content_after, state) + before_snapshot = parse_plan(content_before) + after_snapshot = parse_plan(content_after) + before_counts = Counter(before_snapshot.milestone_texts) + duplicate_milestones = [ + text + for text, count in Counter(after_snapshot.milestone_texts).items() + if count > max(1, before_counts[text]) + ] + rejection_message = ( + "Your task_plan write was not applied because it introduced duplicate top-level " + f"milestones: {duplicate_milestones!r}. When using update_note, replacement must " + "only replace target; do not copy milestones outside target into replacement. " + "Expand target if replacing the whole section. Use distinct milestone titles " + "for intentionally repeated phases. Read the current plan before correcting it." + if duplicate_milestones + else check_plan_mutation_rejections(content_before, content_after, state) + ) if rejection_message is None and not declared_intent: rejection_message = check_unintended_rewrite(content_before, content_after) if rejection_message: @@ -724,10 +740,13 @@ async def _process_plan_write( if task_plan_path.exists(): task_plan_path.write_text(content_before, encoding="utf-8") - # Guidance, not a tool failure: status stays "success" so the Operator - # loop treats it as feedback content (matching the legacy envelope - # behavior where the SystemMessage carried no error status). - return ToolMessage(content=rejection_message, tool_call_id="", status="success") + # Duplicate writes are failed edits: defer dependent actions and let the + # Operator correct the target. Preserve legacy feedback for other guards. + return ToolMessage( + content=rejection_message, + tool_call_id="", + status="error" if duplicate_milestones else "success", + ) # Deterministic check-line guard (Operator-source writes only; the Planner # node writes through unwrapped tools and retains revision authority): @@ -748,8 +767,6 @@ async def _process_plan_write( # nor retired (the ledger, the harvest gate and the run outcome all key a # check item by ``(kind, text)``). Only a line whose ``(kind, text)`` is # gone from the new plan counts as dropped. - before_snapshot = parse_plan(content_before) - after_snapshot = parse_plan(content_after) missing = missing_check_items(before_snapshot, after_snapshot) unprotected = getattr(ctx, "guidance_unprotected_checks", None) if not isinstance(unprotected, set): diff --git a/artemis/interfaces/sdk/task.py b/artemis/interfaces/sdk/task.py index bc25a15..65cb632 100644 --- a/artemis/interfaces/sdk/task.py +++ b/artemis/interfaces/sdk/task.py @@ -15,6 +15,7 @@ import time from typing import Any, Literal from pydantic import BaseModel, Field +from artemis.plugins.models import ExecutionTarget class StreamEventType: @@ -43,13 +44,16 @@ class Task(BaseModel): goal: str = Field(..., description="High-level goal description") profile: Literal["flash", "pro"] = Field(default="pro", description="Execution profile") + execution_target: ExecutionTarget = Field( + default="android", description="Execution target; computer does not require Android" + ) device_id: str = Field(default="default-device", description="Target device serial") device_serial: str | None = Field( default=None, description="Target device serial (alias for device_id)" ) concurrency_mode: Literal["global", "per_device"] | None = Field( default=None, - description="Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)", + description="Legacy option; 2.2.0 serializes all targets even when per_device is supplied", ) locked_package: str | None = Field(default=None, description="Package lock restriction") max_turns: int = Field(default=30, description="Turn cutoff limit") diff --git a/artemis/mcp/action_executor.py b/artemis/mcp/action_executor.py index 3ff4e8d..66495a0 100644 --- a/artemis/mcp/action_executor.py +++ b/artemis/mcp/action_executor.py @@ -59,7 +59,7 @@ from artemis.utils.coordinates import ( ) from artemis.utils.logger import get_logger from artemis.utils.action_target import element_semantics, observation_anchor -from artemis.utils.input_state import verify_input +from artemis.utils.input_state import expected_input_state, verify_input from artemis.utils.image_hash import dhash_hex, hamming_distance_hex from artemis.utils.notes import ( format_list_notes_failure, @@ -290,12 +290,21 @@ class McpActionExecutor: target_coordinates: list[int] | None = None observation_settled: bool | None = None input_verification: str | None = None + expected_input: str | None = None with span: try: session = await self._session_or_start() wire_name, wire_args, finalize, target_semantics = self._translate( raw_name, args, state, index_elements=index_elements ) + if wire_name == "input_text": + expected_input = expected_input_state( + wire_args["text"], wire_args["clear_exist"] + ) + elif wire_name == "focus_and_clear_text": + expected_input = "empty" + if expected_input is not None: + input_verification = "unknown" if raw_name in _POINT_TARGET_ACTIONS: # The normalized point the action was sent to: the recorded # coordinates of an index target (the model named an index, @@ -336,12 +345,13 @@ class McpActionExecutor: if obs.hierarchy_ok: state.indexed_points = [el["center"] for el in obs.elements] state.indexed_elements = obs.elements - if raw_name == "input_text" and res.ok: - input_verification = verify_input(obs.elements, target_semantics) - if input_verification != "present": + if expected_input is not None and res.ok: + if obs.hierarchy_ok: + input_verification = verify_input(obs.elements, target_semantics) + if input_verification != expected_input: message += ( - f" Input value verification: {input_verification}. " - "Input dispatch is not proof of a filled field; observe and " + f" Input value verification: expected {expected_input}, " + f"observed {input_verification}. Observe and " "confirm the real value before submitting. Do not repeat input blindly." ) if shot_path: @@ -362,7 +372,9 @@ class McpActionExecutor: message = f"{message} Post-action screen is still changing; observe again before concluding." status = ( - "success" if res.ok and input_verification not in {"empty", "unknown"} else "error" + "success" + if res.ok and (expected_input is None or input_verification == expected_input) + else "error" ) post_image_name = None if shot_path: diff --git a/artemis/runtime/device_lock.py b/artemis/runtime/device_lock.py index 5071978..90889f6 100644 --- a/artemis/runtime/device_lock.py +++ b/artemis/runtime/device_lock.py @@ -89,9 +89,8 @@ class DeviceLockOwner: class DeviceExecutionLock: """A PID-aware FIFO lease supporting per-device mutexes and global concurrency limits. - Artemis enforces a single active task per mobile device at any time. - Different devices can execute tasks concurrently unless a global concurrency - limit (e.g. concurrency_mode="global" or ARTEMIS_MAX_CONCURRENT_TASKS=1) is configured. + Artemis 2.2 serializes tasks across Android and computer targets. Legacy + concurrency options remain accepted but cannot enable parallel execution. Atomic file creation provides exclusion per device serial. PID creation time prevents stale owners or queue tickets from blocking execution after a process @@ -102,6 +101,7 @@ class DeviceExecutionLock: _DEFAULT_POLL_INTERVAL_SECONDS = 0.1 QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET" LOCK_SCOPE_ENV = "ARTEMIS_ADB_ENDPOINT_ID" + EXECUTION_RESOURCE = "artemis:execution" @staticmethod def _normalize_device_id(device_id: str | None) -> str: @@ -160,7 +160,12 @@ class DeviceExecutionLock: self._queue_path: Path | None = None self._acquired = False self.resource_ids = tuple( - sorted(set(r.strip().casefold() for r in resource_ids if r.strip())) + sorted( + { + self.EXECUTION_RESOURCE, + *(r.strip().casefold() for r in resource_ids if r.strip()), + } + ) ) self._resource_paths: list[Path] = [] @@ -613,7 +618,15 @@ class DeviceExecutionLock: path = self.path.parent / f"artemis-device-resource-{digest}.lock" # Same resource identity applies across plugins and ADB server endpoints. owner = self._get_active_owner_for_path(path) - if owner is not None and owner.token == self.token: + if owner is not None and ( + owner.token == self.token + or ( + resource == self.EXECUTION_RESOURCE + and owner.pid == os.getpid() + and self.session_id + and owner.session_id == self.session_id + ) + ): continue try: fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) @@ -675,37 +688,17 @@ class DeviceExecutionLock: @property def effective_concurrency(self) -> int: - """Resolve effective concurrency limit: - 0: per-device concurrency (multiple devices run concurrently, FIFO per device) - 1: global concurrency (strict single-task serialization across all devices) - >1: global concurrency capped at N tasks across all devices - """ - if self.concurrency_mode is not None: - mode = self.concurrency_mode - if mode in ("global", "serial", "1"): - return 1 - if mode in ("per_device", "device", "parallel", "0"): - return 0 - if self.max_concurrency is not None: - return self.max_concurrency + """All task entrypoints share the current single-task execution policy.""" return self.resolve_env_concurrency() @classmethod def resolve_env_concurrency(cls) -> int: - """Resolve the concurrency limit from the environment. + """Compatibility entrypoint shared with the scheduler: execution is serial. - Shared with the admin scheduler. Returns 0 for per-device concurrency, - or a positive global limit. + Legacy environment/SDK concurrency values cannot opt out in 2.2.0. + The shared execution resource also makes admission atomic across processes. """ - env_mode = os.environ.get("ARTEMIS_CONCURRENCY_MODE", "").strip().lower() - if env_mode in ("global", "serial", "1"): - return 1 - if env_mode in ("per_device", "device", "parallel", "0"): - return 0 - try: - return int(os.environ.get("ARTEMIS_MAX_CONCURRENT_TASKS", 0)) - except (ValueError, TypeError): - return 0 + return 1 def acquire( self, @@ -768,6 +761,7 @@ class DeviceExecutionLock: # 2. Determine FIFO eligibility for this specific device all_wait_files = sorted(self.queue_dir.glob("*.wait")) + is_reentrant = False if effective_concurrency == 1: # In strict serial mode, global FIFO applies; re-entrant parent processes are always eligible active_owners = self.get_active_owners() @@ -794,7 +788,7 @@ class DeviceExecutionLock: ) is_eligible = bool(device_queue and device_queue[0] == self._queue_path) - if is_eligible and self.resource_ids: + if is_eligible and self.resource_ids and not is_reentrant: for earlier in all_wait_files: if earlier == self._queue_path: break diff --git a/artemis/runtime/observation_budget.py b/artemis/runtime/observation_budget.py new file mode 100644 index 0000000..3073fa3 --- /dev/null +++ b/artemis/runtime/observation_budget.py @@ -0,0 +1,51 @@ +"""One observation deadline shared with synchronous screen-client worker threads.""" + +from contextvars import ContextVar +from dataclasses import dataclass, field +from threading import Event +from time import monotonic + + +class ObservationExpired(TimeoutError): + """The shared observation deadline has elapsed.""" + + +class ObservationCancelled(RuntimeError): + """The owning driver has ended this observation.""" + + +@dataclass +class ObservationBudget: + deadline: float + cancelled: Event = field(default_factory=Event) + phase: str = "获取屏幕" + last_failure: str | None = None + + def remaining(self) -> float: + if self.cancelled.is_set(): + raise ObservationCancelled("设备观测已取消,禁止继续重连") + remaining = self.deadline - monotonic() + if remaining <= 0: + raise ObservationExpired(f"设备观测预算耗尽:{self.detail}") + return remaining + + @property + def detail(self) -> str: + return self.phase + (f";最近错误:{self.last_failure}" if self.last_failure else "") + + +current_observation: ContextVar[ObservationBudget | None] = ContextVar( + "screen_observation_budget", default=None +) + + +def check_observation(phase: str | None = None, failure: BaseException | None = None) -> None: + budget = current_observation.get() + if budget is not None: + if failure is not None and not isinstance( + failure, (ObservationExpired, ObservationCancelled) + ): + budget.last_failure = f"{type(failure).__name__}: {failure}" + budget.remaining() + if phase is not None: + budget.phase = phase diff --git a/artemis/runtime/task_control.py b/artemis/runtime/task_control.py index bcb5bbd..4a5b2e2 100644 --- a/artemis/runtime/task_control.py +++ b/artemis/runtime/task_control.py @@ -100,6 +100,12 @@ def write_control_state(session_id: str, status: str, *, reset: bool = False, ** "timing": timing, }, ) + if status == "running" and (control_dir(session_id) / "status.json").is_file(): + from artemis.runtime import trace_store + + # The worker reaches this state only after acquiring its execution lease. + # Admission stays pending until then; never revive a cancelled/finished task. + trace_store.update_trace_status(session_id, "running", expected_status="pending") def request_pause(session_id: str, *, reason: str | None = None) -> None: diff --git a/artemis/runtime/trace_store.py b/artemis/runtime/trace_store.py index 7cb1f01..36a1a67 100644 --- a/artemis/runtime/trace_store.py +++ b/artemis/runtime/trace_store.py @@ -288,12 +288,16 @@ def update_trace_status( error: str | None = None, result: Any | None = None, device_serial: str | None = None, + *, + expected_status: str | None = None, ) -> dict[str, Any] | None: """Updates specific fields of the status.json for a given trace_id. The read-modify-write cycle holds a cross-process lock so concurrent updates from the daemon, MCP tools, and worker processes do not lose each other's fields. + When expected_status is supplied, apply the update only if it still matches + inside the lock; a late worker transition must not revive a terminal task. """ path = get_status_path(trace_id) with _status_lock(path): @@ -306,6 +310,9 @@ def update_trace_status( ) return None + if expected_status is not None and data.get("status") != expected_status: + return data + # "success" is a legacy alias for the canonical "completed" terminal # status and must never be persisted into status.json. if status == "success": diff --git a/artemis/tools/android_files.py b/artemis/tools/android_files.py index 4eb4940..04962f1 100644 --- a/artemis/tools/android_files.py +++ b/artemis/tools/android_files.py @@ -336,7 +336,9 @@ android_file = AndroidFileTool( "Use action=screenshot to save the CURRENT phone screen as native PNG, without UI shortcuts " "or the computer-use plugin. Default host location is this task's trace/artifacts/android; " "use destination=desktop only when the user requests the PC desktop. Returns verified paths, " - "size and SHA-256; never claim success without a successful result. Screenshot capture does " + "size and SHA-256; copy trace_path verbatim into evidence notes, without rebuilding it " + "from the session directory and filename. Never claim success without a successful result. " + "Screenshot capture does " "not prove the page is correct. Capture only after intended UI actions have executed and " "the resulting screen has been observed, not alongside pending UI actions. " "list/read_text/write_text operate on absolute ANDROID paths; " diff --git a/artemis/utils/input_state.py b/artemis/utils/input_state.py index 9e55415..372f2c5 100644 --- a/artemis/utils/input_state.py +++ b/artemis/utils/input_state.py @@ -54,6 +54,13 @@ def input_state(node: dict[str, Any]) -> dict[str, Any]: } +def expected_input_state(text: str, clear_existing: bool) -> str | None: + """Empty replacement means clear; empty append has no value postcondition.""" + if text: + return "present" + return "empty" if clear_existing else None + + def verify_input(elements: list[dict], target: dict | None = None) -> str: """Verify value presence against a fresh tree, without returning its value. diff --git a/artemis/utils/notes.py b/artemis/utils/notes.py index 63f2d39..c7ccb69 100644 --- a/artemis/utils/notes.py +++ b/artemis/utils/notes.py @@ -53,7 +53,11 @@ SAVE_NOTE_DOCSTRING = ( " will be overwritten." ) SAVE_NOTE_ARG_KEY_DESC = "The unique key under which to save or overwrite the note." -SAVE_NOTE_ARG_CONTENT_DESC = "The text content to write to the note." +_ARTIFACT_REFERENCE_RULE = ( + " Copy artifact paths exactly from successful tool receipts (trace_path for task-local " + "evidence); never rebuild a path from the session directory and filename." +) +SAVE_NOTE_ARG_CONTENT_DESC = "The text content to write to the note." + _ARTIFACT_REFERENCE_RULE APPEND_NOTE_DOCSTRING = ( "[NOTE] Appends text content to an existing note in persistent memory with" @@ -62,7 +66,7 @@ APPEND_NOTE_DOCSTRING = ( " note does not exist, it will be created." ) APPEND_NOTE_ARG_KEY_DESC = "The key of the note to append to." -APPEND_NOTE_ARG_CONTENT_DESC = "The text content to append to the note." +APPEND_NOTE_ARG_CONTENT_DESC = "The text content to append to the note." + _ARTIFACT_REFERENCE_RULE UPDATE_NOTE_DOCSTRING = ( "[NOTE] Updates a note by replacing a specific target string with" @@ -77,6 +81,7 @@ UPDATE_NOTE_ARG_TARGET_DESC = ( ) UPDATE_NOTE_ARG_REPLACEMENT_DESC = ( "The new replacement content to insert instead of the target content." + + _ARTIFACT_REFERENCE_RULE ) @@ -146,8 +151,40 @@ def read_note_content( return content +def validate_artifact_note_paths(base_dir: str | Path, content: str) -> None: + """Reject missing root-level references to existing task artifacts, without guessing.""" + root = Path(base_dir).resolve() + if not any(prefix in content for prefix in (str(root), root.as_posix())): + return + artifacts = root / "artifacts" + if not artifacts.is_dir() or not artifacts.resolve().is_relative_to(root): + return + candidates: dict[Path, list[str]] = {} + for path in artifacts.rglob("*"): + if not path.is_file() or not path.resolve().is_relative_to(root): + continue + misplaced = root / path.name + if misplaced.exists(): + continue + for spelling in {str(misplaced), misplaced.as_posix()}: + if re.search(re.escape(spelling) + r"(?![\w./\\-])", content): + candidates.setdefault(misplaced, []).append(str(path)) + break + if candidates: + details = "; ".join( + f"{missing} does not exist; saved candidates: {', '.join(paths)}" + for missing, paths in candidates.items() + ) + raise ValueError( + "Artifact reference has omitted its artifact directory. " + + details + + ". Re-read the tool receipt and use its exact trace_path; note was not changed." + ) + + def save_note_content(base_dir: str | Path, key: str, content: str) -> None: """Saves or overwrites the raw content of a note.""" + validate_artifact_note_paths(base_dir, content) notes_dir = get_notes_dir(base_dir) notes_dir.mkdir(parents=True, exist_ok=True) file_path = get_note_file_path(base_dir, key) @@ -211,6 +248,7 @@ def save_verified_note(base_dir: str | Path, key: str, content: str) -> dict: def append_note_content(base_dir: str | Path, key: str, content: str) -> None: """Appends raw content to a note, inserting a newline if the file is not empty and lacks a trailing newline.""" + validate_artifact_note_paths(base_dir, content) notes_dir = get_notes_dir(base_dir) notes_dir.mkdir(parents=True, exist_ok=True) file_path = get_note_file_path(base_dir, key) @@ -379,6 +417,7 @@ def update_note_content( # Phase 4: Write and Return, or Not Found Error if new_content_to_write is not None: + validate_artifact_note_paths(base_dir, replacement) file_path.write_text(new_content_to_write, encoding="utf-8") if key == "task_plan": try: diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 4704feb..7a9c7be 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -1,4 +1,4 @@ -# 使用 AI 完成 Android 自动化 +# 使用 AI 完成 Android 与电脑自动化 官方 `skills/artemis-use/` 随源码和 wheel 分发,包含执行、探索、诊断复盘及插件开发规范、模板和校验脚本。启动脚本、直接 `artemis ui` / `artemis-admin` 启动服务、显式 `artemis mcp --install codex` 共用同步逻辑;MCP 桥连接/握手不安装、不启动主程序。 @@ -26,6 +26,8 @@ Flash 和 Pro 都支持任务内的安卓文件工具 `android_file`:保存原 ## MCP 能力 +仅 PC 测试与网页“仅 PC”勾选项使用同一任务目标。先用 `mobile_workspace(include_devices=false)` 查看实际电脑插件,再提交 `mobile_run_task(task_desc="在电脑浏览器检查指定页面", model="Flash", request_id="本次提交唯一编号", runtime_overrides={"execution_target":"computer"})`。无需手机或模拟器在线;仍需可用模型和已启用并授权的电脑观察插件。`model="Pro"` 与 `mobile_exploration` 的 `task_config.execution_target="computer"` 同样适用。每项批量用例的 `task_config` 使用相同字段;插件参数见 [插件任务参数](../skills/artemis-use/references/plugins.md)。 + | 工具 | 功能 | | --- | --- | | `mobile_diagnose` | 环境、凭据、设备诊断和自带修复 | diff --git a/docs/automation-workspace.md b/docs/automation-workspace.md index 6e4ad63..fe86eb9 100644 --- a/docs/automation-workspace.md +++ b/docs/automation-workspace.md @@ -2,6 +2,18 @@ 工作区右侧或顶部的 **报告与自动化** 入口包含结论报告、批量任务。历史任务在任务队列中按浏览器本地日期分组,支持名称、目标、编号和标签搜索,以及日期、状态筛选和分页。 +## 仅 PC 测试 + +首页任务输入区的“仅 PC”勾选项适用于只测试电脑桌面或浏览器的用例。勾选后 Flash、Pro、探索均不要求 Android 设备或模拟器在线,也不检查 Android 锁屏;仍需 Python、有效模型配置,以及已启用并授权、提供电脑画面观察的插件(例如 CUA)。取消勾选恢复默认手机任务。扩展插件列表随目标筛选,切换目标会移除不兼容的手动选择;所用版本与桌面资源仍由统一队列固定和保护。 + +单任务 HTTP/MCP 使用 `runtime_overrides: {"execution_target":"computer"}`;批量用例在“任务配置(JSON)”填写 `{"execution_target":"computer"}`,无需填写 `device_serial`。可同时指定 `plugins`;不提供时使用匹配目标的授权默认插件,`plugins: []` 无法运行需要画面观察的仅 PC 任务。混合手机与电脑的用例仍需在线手机,不勾选“仅 PC”。 + +## 串行执行与观测故障 + +2.2.0 中所有任务按提交顺序串行执行,包括仅 PC、Android 和跨端任务;网页、MCP、批量 JSON、CLI 与 SDK 共用队列和跨进程执行锁。当前不开放跨设备并行,旧版 `per_device`、`parallel`、`max_concurrency` 等设置也不会启用并行。队首等待资源时,后续任务继续排队。 + +模拟器窗口仍在运行,不代表屏幕辅助服务、UIAutomator2 或 ADB 截图一定正常。观测失败时保留所处阶段与最近错误;辅助服务回退和 UIAutomator2 重连共用本次观测预算,退出任务会取消后续恢复,并清理迟到连接。不能把这类基础设施故障直接判为被测应用缺陷。 + ## 多页面与加载排查 同一浏览器、同一服务地址的可见页面共享一条任务实时连接;持有连接的页面关闭后,其他页面自动接管。后台页面暂停实时订阅和设备画面预览,返回前台时重新读取任务状态、步骤和结论,不影响后台任务执行或录屏。旧浏览器不支持共享连接时,仍会释放后台页面的实时连接。 diff --git a/docs/development-handoff.md b/docs/development-handoff.md index a671d45..f9c8ef2 100644 --- a/docs/development-handoff.md +++ b/docs/development-handoff.md @@ -1,5 +1,9 @@ # 开发交接:2026-10-19 +2026-10-27 发布主线已整理为 **ARTEMIS 2.2.0**,配套插件为 **Cua 0.38.0+artemis.1**,Helper 为 **1.2.3**。最新升级内容、验证与未测范围以[2.2.0 发行说明](releases/2.2.0.md)为准;下方“尚未发布”、分叉计数和候选版本均属于对应日期快照。插件从独立工作树发布,原插件主目录的 OCU 开发现场保留。 + +2026-10-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。 + 2026-10-24 开发主线已迁入新会话,迁入基线为 2.1.0;2026-10-26 工作区已升至 2.2.0,尚未发布,完整增量见[2.2.0 说明](releases/2.2.0.md)。新增工作与待办优先级见[本轮主线计划](plans/2026-10-25-mainline.md);以下保留原始迁入快照,不将其提交号、分叉计数或测试数量视为当前状态。 本文件保留 2026-10-19 从《Artemis开发主线-1017-2》迁入时的开发快照。历史结论已结合代码、发行说明和当时的本地 Git 引用核对;后续开发以[仓库指南](../AGENTS.md)、[设计约定](../DESIGN.md)和实时 Git 状态为准,以下提交号与分叉计数不随后续提交自动更新。 diff --git a/docs/plans/2026-10-26-session-handoff.md b/docs/plans/2026-10-26-session-handoff.md new file mode 100644 index 0000000..5066097 --- /dev/null +++ b/docs/plans/2026-10-26-session-handoff.md @@ -0,0 +1,119 @@ +# 开发主线:2026-10-26 会话接续 + +本次依据用户提供的上一会话完整记录,并核对两个仓库及 CUA 隔离工作树。本文是当前接续入口;此前实现过程与验证见[上一阶段主线](2026-10-25-mainline.md),最终变更清单见[2.2.0 发行说明](../releases/2.2.0.md)。 + +## 迁入时核实的开发基线 + +| 范围 | 迁入时状态 | +| --- | --- | +| 主程序 | `main`,HEAD 为 `fac4ccb76075b3360b11053ecb0e33ade9d94a61`,提交信息为 `feat: prepare 2.2.0 with UI and execution fixes`;相对本地 `eapil/main` 跟踪引用领先 1 个提交。已跟踪文件无修改,仅有未跟踪的 `.angular/` 缓存。 | +| 版本与交付 | 主程序及前端版本为 2.2.0;上一会话已完成本地提交,记录为未推送、未打标签、未发布源码包。本次未刷新远端,HEAD 没有本地标签。 | +| 插件主目录 | `main` 为 `757b829`,相对本地 `origin/main` 领先 4 个、落后 7 个提交;有原有已修改及未跟踪文件,不能作为发布现场直接合并或整体提交。 | +| CUA 隔离工作树 | 分支 `codex/cua-shell-observation`,HEAD 为 `dccddaa`;候选 `0.38.0+artemis.1` 的源码、测试和文档仍为未提交改动,市场尚未发布该候选。 | +| 已安装插件 | 上一会话记录本机已安装并启用此 CUA 候选;本次未查询运行服务或重新核验实际安装,不由仓库版本推定运行状态。 | + +以上分叉计数均来自本地跟踪引用,属于此次接续快照。 + +## 已完成内容与持续约束 + +上一阶段 P1、P2 和已确认的 P3 均已实现:Markdown 与暗色模式、Flash 开关即时保存、真实输入与目标证据、模型响应完整性、启动恢复、取消与请求诊断、生命周期与指导回执、证据来源及 MCP/HTTP 协议改进。具体行为以发行说明和当前源码为准,不重复列为待开发功能。 + +用户明确要求移除的次数配额、自然语言约束编译、提交内容指纹和 AI 二次动作否决已完整撤回。旧计划中相关修复与较高测试数量属于历史过程,不代表当前机制。后续不恢复该机制,也不增加替代审批层。模型依据用户任务原文、指导、观察和历史遵守次数及禁止要求;资源锁、既有工具参数与插件授权校验、暂停取消和真实工具错误处理继续保留。 + +保护现有配置、历史任务及两个仓库的开发现场;模型请求重试不自动重放业务动作。运行中的服务是否加载新代码需独立核对,不能由源码版本或旧构建记录推定。下一次正常重启后再核验后台行为,不因迁移会话而停止任务或重启服务。 + +## 后续工作与验收缺口 + +1. **主程序外部验收。** 在明确的设备和业务范围内,以新任务核对首次输入与发送、审批动作、Flash 最终判定设置、暂停及新指导、响应与超时诊断、报告来源。保留原失败任务和结论,将新任务证据与确定性回归分开记录。 +2. **独立 CUA 候选收尾。** 在隔离工作树核对最终源码、补丁、包内构建来源、版本及校验值,按插件仓库指南完成所需回归,再单独整理提交与发布范围。原业务流程、其他托盘应用、多屏和 DPI 组合仍有未测范围;主程序升级不会自动升级插件。 +3. **2.2.0 发布。** 主程序已有本地提交;后续推送、标签与源码包按发布任务推进,分别核对远端提交、交付范围及实际验证。插件候选不能夹带进入主程序发布。 +4. **后续产品候选。** 素材准备句柄、跨次任务取代关系仍属待明确需求,尚未排为本轮实现;不据此自动扩展调度、状态存储或历史结论覆盖行为。 + +## 验证记录的使用 + +上一阶段最终记录为:撤回机制后默认后端 **3063 项通过、2 项跳过、8 项排除**,专项 **38 项通过**;Ruff 两项、质量基线及核心类型检查通过。升版后启动与打包等专项 **44 项通过**,前端 **317 项通过**、生产构建通过。CUA 候选另有 Go 检查及隔离桌面验证,详见插件自身记录。 + +以上是上一会话的验证结果。本会话新增修复与验证见下节;未执行设备业务任务或调用付费模型。 + +## 本会话修复:动作卡片复制与离线诊断 + +用户反馈任务 `58b6b4cc-5dbe-49dd-9b46-187d37e2339e` 的 `Pixel verification failed`。日志显示实时截图连续失败,后续明确出现 `device offline`,用户也已确认设备离线;该次点击没有下发。保留原任务与历史记录,不自动重跑。 + +- 共用的动作卡片允许选择文字,可复制目标、参数、错误和 `ATTEMPTS` 等内容;拖选标题不会触发展开/收起,普通点击继续切换。已在实际工作区页面拖选“点击元素”并通过 Ctrl+C 验证复制内容,卡片保持展开。 +- 像素校验无法完成时使用 `pixel_verification_unavailable`,保留截图、超时或模型解析的具体错误。观察不可用不再误归为目标消失,也不缓存为被拒绝的目标;实际识别出目标消失时仍使用原分类。既有校验与重试次数保持不变。 +- 前端生产构建已由运行服务加载并验证;后台服务未重启,后端诊断调整在下一次正常重启后生效。 + +本次验证:默认后端回归 **3063 项通过、6 项跳过、8 项排除**;定向后端回归 **50 项通过**;前端 **320 项通过**、生产构建通过。Ruff 格式与静态检查、质量基线、核心类型检查及 `git diff --check` 均通过。后端回归仍有 Pillow `getdata()` 的弃用警告;本次未执行设备业务复测。 + +## 本会话新增:仅 PC 测试 + +- 首页输入区新增符合主题的“仅 PC”勾选项,默认关闭;Flash、Pro、探索共用 `execution_target="computer"`。开启后标题、提示和插件列表切换为电脑,不再因 Android 离线、锁屏或 ADB 工具未就绪阻止提交;运行时、模型和授权的电脑观察插件仍必需。 +- 复用现有后台任务队列、固定插件版本和桌面资源锁。MCP 的任务追踪统一绑定 `computer:local`,忽略传入的离线手机;批量“任务配置(JSON)”使用 `{"execution_target":"computer"}`,模板说明页补充例子,未增加会被意外执行的示例用例。 +- 远程 Python SDK 的 `submit/run` 和兼容 `Task` 支持 `execution_target`;修正 `options` 对应的 HTTP 字段为 `runtime_overrides`,仅 PC 不携带客户端保存的手机序列号。MCP 离线工具目录同步工具说明,工具名称和白名单未改动。 +- 实际网页验证 Android 离线时勾选后执行按钮可用、取消后恢复阻塞,电脑插件筛选正常、控制台无错误;未提交真实任务或调用模型。前端新构建已加载,后台和 MCP 后端代码需在下一次正常重启后完整加载,本轮未重启服务。 + +合并前述修复后的最新验证:默认后端 **3072 项通过、6 项跳过、8 项排除**;前端 ChromeHeadless **326 项通过**,生产构建通过;Ruff 格式与静态检查、质量基线、核心类型检查、差异空白检查均通过,10 份变更文档的 67 个本地链接有效。后端保留 102 条既有 Pillow 弃用警告。未执行真实 PC 或 Android 业务用例,不将网页提交可用或确定性回归等同于端到端验收。 + +## 本会话后续修复:截图坐标、串行与观测恢复 + +- 像素校验按每张编码截图的尺寸换算原生设备坐标,红点及拒绝缓存裁剪使用同一转换;保留实际派发坐标,方向变化时要求重新观察。 +- 2.2.0 固定所有目标串行执行,统一调度与原子共享执行锁覆盖网页、MCP、批量 JSON、CLI 和 SDK。旧并行参数保留兼容接收但不能启用并行;队首等待资源时不允许后续任务越过。保留同任务嵌套持锁、暂停释放和恢复排队。 +- 观测预算传入 Helper 回退和 UIAutomator2 同步线程,启动等待与重试共用期限;退出后阻止后续恢复,清理并丢弃迟到连接,超时保留阶段和最近错误。 +- 第57步失败前 Helper、UIAutomator2 和 ADB 截图均失去响应;连接在收尾期间才恢复。模拟器窗口运行不等于观测服务正常,最初卡顿的设备端原因未确认,不归为已证实离线或应用缺陷。 +- RESULT 默认折叠、全文限高滚动与可复制已完成并在前一轮浏览器验证。本轮按用户要求暂缓 CUA 鼠标问题,等待复现。 + +本轮实际验证:默认后端 **3095 项通过、2 项跳过、8 项排除**,独立远程 SDK **24 项通过**;Ruff 格式与静态检查、质量基线、核心类型检查均通过,保留102条既有Pillow弃用警告。前端沿用上一轮326项测试及构建记录,本轮未再修改前端。未重启服务、未重跑设备业务任务、未提交或发布;完整加载后台修复需下一次正常重启。 + +## 后续实测发现与修复:Pro 收尾空转 + +后续真实测试中,安卓输入和清空均已完成,但普通 Pro 没有从内部工具循环及时返回最终验收;完成说明仍被当成缺少工具调用而继续恢复。另有局部 `update_note` 的 replacement 夹带 target 范围之外的后续里程碑,产生重复待办。这里的失败不能归因于手机离线。 + +- 普通 Pro 的完整计划可以直接返回既有收尾流程,包括最后一次允许的辅助工具调用;完成说明不再强制转成设备动作。未完成子任务、持续循环、新指导和验收反馈保留各自处理规则,探索仍需交付报告,执行完成不直接成为通过结论。 +- Operator 的三种计划写入共用重复顶层里程碑检查;若同名项数量新增或增加,撤销写入并返回工具错误,提示修正替换范围。已有重复项可以减少或整理,正常状态更新不受影响。 +- 本轮确定性验证:专项 **103 项通过**;默认后端 **3112 项通过、2 项跳过、8 项排除**,仍为102条既有Pillow弃用警告。Ruff 两项、质量基线、核心类型检查通过。 +- 本次修复后尚未执行设备复测,也未重启服务、提交或发布。上一轮浏览器精确大小写断言失败和历史筛选期间使用旧坐标的问题,不据此归为已确认的产品代码缺陷;RESULT 交互的真实任务复测仍未完成。CUA 鼠标跟随问题继续按用户要求等待复现。 + +## 后续实测修复:输入清空与结果判定 + +- 旧全量清空依赖全选后固定 21 次退格;23 字符测试串在全选未生效时剩下 `AR`。已统一使用控件清空接口,清空失败停止后续输入,空文本不再触发粘贴;回执丢失不跨后端重复派发清空。 +- Pro、Flash 按意图核对新鲜控件状态:输入要求 `present`,空文本替换要求 `empty`,未知状态不判成功。状态确认不等于最终业务通过。 +- 实测进一步定位 Helper 的焦点节点选择错误:键盘窗口的不可编辑聚焦容器排在应用输入框之前。Helper 1.2.3(versionCode 9)跳过不可编辑、不可用或不可见的聚焦节点;源码、APK 与校验清单同步构建,模拟器已实际加载新版。 +- Pro 实测首次清空成功,记录 `input_verification=empty`;正式验收为 5 项通过、1 项未验证,原因是禁止发送等断言未产出判定,不覆盖原结果。计划未同步时仍发生两次额外点击,随后将恢复提示改为先核对并同步计划、禁止为凑工具调用追加点击;该提示尚未再次进行 Pro 设备复跑。 +- Flash 独立实测完整通过:点击、输入、截图、首次清空、截图、报告后进入最终验收;未追加点击,队列恢复空闲。8 次模型请求全部为 `gpt-6-luna / high`,没有注入指导。 +- 最终确定性验证:默认后端 **3145 项通过、2 项跳过、8 项排除**;输入专项 **105 项通过**;Helper 构建后相关回归 **81 项通过**。Ruff 两项、质量基线、核心类型检查和差异空白检查通过;保留 102 条既有 Pillow 弃用警告。未重启主服务、未提交或发布。 + +## 补充实测:Pro 输入与收尾 + +用户要求继续实测后,Pro 独立回归正式验收通过:6 项通过、0 项失败、0 项未验证,约 217 秒完成。Helper 为 1.2.3,26 次请求均为 `gpt-6-luna / high`,没有请求重试或中途指导注入。完整输入、首次清空和两张截图均有实际证据;清空后只整理笔记与读取历史证据,没有追加点击或输入,队列恢复空闲。 + +本轮正常同步计划并收尾,没有触发此前的无工具调用恢复分支,因此记录为本轮未复现,不推断已穷尽所有计划不同步场景。本轮未修改程序代码、未重跑单元测试,保留之前任务的原结论。 + +## 集中回归及回执、证据引用修复 + +集中回归正式用例 9/9 通过,覆盖安卓三轮输入清空、暂停/恢复、PC 接续、安卓/PC/跨端各两轮,以及最终只读连接复检;针对性自动化检查 313 项通过。全部 224 次模型请求为 `gpt-6-luna / high`,包含探索准备 33 次和正式用例 191 次;一次请求因计划内暂停取消,没有请求重试或正式任务指导注入。探索准备另有 1 项范围约束失败(读取原 Brave 页面),原结果保留,不计入正式用例通过数。 + +用户随后要求修复回归发现的两个问题: + +- MCP 在派发前标记 `pending`,收到入队回执后不再强行覆盖为 `running`;返回调度器任务状态,并优先保留工作进程已经写入的新状态。丢失回复后的队列补查使用相同规则。工具说明和离线目录同步注明排队状态及全局串行。 +- Android 文件工具和笔记参数明确要求原样引用成功回执的 `trace_path`。新增笔记写入校验:当任务根目录的绝对文件引用不存在、但任务 `artifacts/` 中有同名文件时,拒绝本次写入并列出候选真实路径,供模型核对回执后修正;不自动移动文件或猜选同名证据。覆盖保存、追加、局部更新;已有根目录文件及没有同名证据的未来路径不按此规则拒绝。 +- 原安卓三轮笔记的 5 条遗漏目录路径已只读复核,均能检出,原始笔记未更改。新增端到端工具测试验证截图一次后,错误笔记写入被拒绝、按回执路径重写成功,无需重新截图或执行设备动作。 +- 修复后默认测试 **3162 项通过、2 项跳过、8 项排除**,保留 102 条已有 Pillow 警告;Ruff、格式、质量基线、核心类型和差异空白检查通过。 +- 运行中的 ARTEMIS/MCP 未重启,修复后的真实任务尚未复测;下次正常重启/重连加载代码后再验收实时行为。历史回归成绩不冒充本次修复后实测。 + +## 用户重启后的实测与状态补修(10 月 26 日晚至 27 日凌晨) + +- 截图路径校验已在真实 Pro 任务中触发:遗漏目录的引用被拒绝,使用成功回执完整路径后保存及追加成功;5 张截图的文件、笔记路径与 SHA-256 核对一致。负向测试中一次 Android 文件工具参数误用被拒绝,另有说明文字引用错误路径而再次被拒绝;这些实际过程均保留。 +- 实测发现入队改为 pending 后,执行进程未将 MCP 状态更新为 running。现由工作进程取得资源、写入 running 控制状态时同步推进已有状态文件;锁内只允许 pending → running,避免迟到更新覆盖取消或终态。新工作进程已实际加载补丁;MCP 观察到 C running / D pending,随后 C completed / D running,最终均 completed,时序无重叠。 +- 本轮四个真实任务正式结果为 **3 passed、1 inconclusive**。最后一个用例将提示中的排队说明误解为需要从手机观察队列,补充一次 MCP 调度证据后完成截图;指导已消费,不能记为无指导通过。 +- 新发现尚未修复:最后用例的 Checker 将 `[verify] (on_complete)` / `[assert] (at_end)` 展示前缀写进 item_text,原文精确匹配未识别已覆盖项,同一三条要求被补为未验证。原始结论保持 3 项通过、3 项未验证,没有人工改判或自动重跑。 +- 本轮共 61 次模型请求,均为 `gpt-6-luna / high`、completed,无请求重试;最终队列空闲、设备在线且未占用,未操作电脑。详细原始记录与审计保存在本地 artifacts/regression-fixes-20261026/live-results.md。 +- 最新专项 85 项通过;完整默认测试 **3168 项通过、2 项跳过、8 项排除**,仍为 102 条已有 Pillow 警告;Ruff、格式、质量基线、核心类型及差异空白检查均通过。未提交或发布。 + +## 验收项展示标签匹配修复(10 月 27 日) + +用户要求修复上述新增问题后,Checker 在完整 JSON 直接复用和结构化结果归一化两条路径中,共用本轮检查项展示标签的精确映射。仅还原本轮实际生成的 `[kind] (when)` 标签及可选列表符号;正文、类型或时点不同不模糊匹配,字面正文优先,已有失败和矛盾证据不丢弃。模型输出字段说明同步要求只返回检查正文。 + +- 原失败场景的三条模型结论只读复核后恰好匹配三个原检查项,不再额外产生未验证项;旧 ledger 未改写,历史正式结论保持原样。 +- 新安卓 Pro 实测任务 `0cacbe39-4fe3-4995-bc2a-d7a034b28a27` 正式通过:3 项通过、0 项失败、0 项未验证。截图、笔记路径和 SHA-256 核对一致。11 次模型请求均为 `gpt-6-luna / high`、completed,无请求重试、无中途指导;新工作进程已加载修复。 +- 针对性测试 74 项通过;最新默认测试 **3189 项通过、2 项跳过、8 项排除**,102 条已有 Pillow 警告。Ruff、格式、质量基线、核心类型及差异空白检查通过。 +- 最终队列空闲、安卓在线且未占用。详细验证及边界见本地 artifacts/checker-label-fix-20261027/results.md;未提交或发布。 diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md index d954ee3..51c737b 100644 --- a/docs/releases/2.2.0.md +++ b/docs/releases/2.2.0.md @@ -1,57 +1,85 @@ # ARTEMIS-Eapil 2.2.0 -日期:2026-10-26。比较基线:`v2.1.0`(`83bcfd5`)至当前工作区,包括本轮新增文件。 +日期:2026-10-27。升级基线:`v2.1.0`(`83bcfd5`)。 -主程序、前端及各自锁文件已统一为 **2.2.0**。当前为本地开发提交,尚未推送、创建标签或发布源码包。远程薄客户端 `artemis-client` 与安卓 Helper 沿用各自独立版本。 +本版新增无需手机在线的仅 PC 测试,统一 Android、PC 与跨端任务的串行执行,并修复安卓输入清空、观测恢复、排队状态与最终验收匹配问题。网页新增暗色模式、Markdown 表格和可折叠、可复制的工具结果。 -## 主程序变更清单 +主程序、前端及锁文件为 **2.2.0**;Android Accessibility Helper 为 **1.2.3**;配套 Cua 插件独立发布 **0.38.0+artemis.1**,继续标注 Windows x64 预览。远程 Python 客户端沿用独立的 0.1.0 版本。 -| 范围 | 2.1.0 → 2.2.0 的变化 | +## 任务入口与执行 + +| 升级内容 | 2.2.0 行为 | | --- | --- | -| Markdown | 计划、笔记和报告复用渲染器,支持表格与对齐、嵌套列表、任务清单、引用、链接、图片、删除线和围栏代码;保留计划进度与验收标记,处理 HTML 转义、链接协议和流式未闭合代码围栏。 | -| 暗色模式 | 导航太阳/月亮按钮一键切换,首次跟随系统,浏览器记忆并在首次绘制前应用;首页、工作区、配置、插件和报告共用亮暗主题。 | -| Flash 最终 AI 判定设置 | 开关切换后立即保存,保存中防止重复操作,失败恢复原值;保存成功后后续未开始任务使用新值,无须重启。默认关闭、运行任务配置快照及 Pro 验收行为保持原约定。 | -| 插件结果阅读 | 窗口状态、控件与工具结果采用摘要、分组及可展开原始数据展示,改善 `get_window_state` 大 payload 的阅读;此次优化网页呈现,保留执行模型收到的工具证据。 | -| 安卓输入真实性 | 区分输入值、占位提示、可编辑性、焦点与掩码;保留空输入框,输入后重新观察,避免把提示文字当成已经填写。未确认输入时停止继续批量提交,保留已派发事实。 | -| 安卓 Helper | 升至独立版本 **1.2.2**,补充节点输入相关属性,同步 APK、清单与校验信息。 | -| 点击定位与历史引用 | 动作保留实际目标、坐标、截图和快照身份,旧步骤不会随控件编号复用而改变;像素定位结合真实控件和局部画面,避免改称空白区域绕过目标检查。 | -| 写操作错误处理 | 不因一次执行错误自动重复物理动作;区分未派发、已派发和结果不明,扩展工具错误及证据回执保存失败不会被错误记作成功或诱发重发。 | -| 应用启动恢复 | 启动重试不再隐式强制停止应用;焦点为空时结合已恢复活动所在任务判断,仅可见的候选任务不足以证明已就绪,保留有界等待与诊断。 | -| 模型响应完整性 | 流式、非流式、结构化结果及串接解析器要求服务端完整结束;缺少终止事件、失败、未完成或截断 JSON 不进入工具与正式结论。恢复只重试无效模型请求,保留工具证据,不重新执行业务动作。 | -| 取消、超时和请求诊断 | 传递用户停止、新指导、暂停、任务/检查替换及已知外层时限;区分单次请求、传输与验收总预算。用请求和尝试编号关联重试,显示可用服务端请求、响应及链路编号;未知来源和 HTTP 499 保留未知。 | -| 任务生命周期 | 网页、MCP 与报告共用阶段和耗时投影,区分排队、资源等待、暂停/示范、执行、最终验收和导出;增加实际工作进程开始、导出完成时点,旧数据缺失项不补成零。 | -| 中途指导回执 | 区分接收、领取与消费;终态尚未消费、领取后未确认消费分别显示,收到指导不再等同于已经执行。 | -| 证据来源与验收范围 | 页面、Word/Excel、报告和证据索引汇总实际执行界面及接口、终端、文件等辅助来源,保留步骤和调用引用;最终复核明确未验证边界,不按网址或业务名称猜测平台。 | -| MCP、HTTP 与阶段更新 | 拒绝未知字段,JSON 工具结果提供 `structuredContent` 与兼容文本;历史摘要带总数、下一页和实际存储步号;任务阶段支持按 id 局部更新,保留其他阶段并原子校验。工具名称和白名单不变。 | +| 仅 PC 测试 | 首页增加“仅 PC”勾选项,Flash、Pro 和探索均可在没有在线 Android 时运行。仍需有效模型和已启用、授权的电脑观察插件;切换目标同步更新就绪状态及插件筛选。 | +| MCP、HTTP、SDK 与 JSON | 单任务通过 `runtime_overrides: {"execution_target":"computer"}` 指定电脑目标;批量“任务配置(JSON)”使用 `{"execution_target":"computer"}`;Python 客户端支持 `execution_target="computer"`。混合任务仍需在线 Android。 | +| 全局串行 | Android、仅 PC 和跨端任务共用 FIFO 队列与跨进程锁,当前不开放跨设备并行。网页、MCP、批量、CLI 与 SDK 使用同一调度。暂停后可释放资源,恢复时重新排队。 | +| 排队状态一致 | MCP 提交、丢失响应后的查询和工作进程启动按真实状态更新。等待资源显示 pending,获得执行权后显示 running;迟到更新不能覆盖已完成或已取消状态。 | +| Flash 最终 AI 判定 | 开关切换即保存,失败恢复原值;保存后未开始任务采用新值,无须重启。已运行任务仍使用启动时配置,Pro 保留最终验收。 | +| Pro 收尾 | 已完成计划进入最终验收,减少结束阶段重复操作;保留计划进度与恢复信息。 | -## 本轮撤回的机制 +详细入口见[工作区说明](../automation-workspace.md)、[AI 自动化](../ai-automation.md)和[Python 客户端](../../packages/artemis-client/README.md)。 -开发中曾自行加入次数约束编译、配额持久化、提交内容指纹和 AI 二次动作否决,造成首次 Send、Approve 被误拦。已按用户明确要求完整删除运行时、调用接入及专属测试,没有留下开关或替代审批层。 +## Android 与操作可靠性 -2.2.0 由执行模型依据用户原文、指导、观察与历史遵守任务次数和禁止要求。资源锁、既有参数及插件授权校验、暂停取消、输入状态观察和真实错误处理继续保留。旧任务及历史约束记录保留,不再作为执行配额读取。 +| 修复 | 变化 | +| --- | --- | +| 输入与清空 | 使用控件清空能力,不再依赖固定次数退格;输入、清空后重新观察真实值。区分占位提示、可编辑性、焦点及掩码,不把提示文字当成输入成功。 | +| Helper 1.2.3 | 补全输入属性,排除键盘窗口中的不可编辑焦点容器,避免选错输入目标。同步 APK、版本清单与 SHA256。 | +| 观测与连接恢复 | Helper 回退、UIAutomator2 重连和截图共用本次观测预算,取消时停止恢复并回收迟到连接。模拟器窗口运行不等于 ADB、截图或辅助服务正常;诊断保留实际失败阶段。 | +| 像素定位 | 按真实截图尺寸映射坐标,检查旋转及快照时效;历史目标保留自己的坐标和快照身份,避免控件编号复用导致引用变化。 | +| 写入失败 | 区分未派发、已派发和结果不明,不因工具错误或证据回执保存失败重复物理动作。 | +| 应用启动 | 重试不再隐式强制停止应用;结合恢复中的活动和焦点判断就绪,保留有界等待及诊断。 | -## 同期 CUA 插件候选 +## 验收、证据和诊断 -此项位于独立的 `Artemis-Plugins` 仓库和隔离工作树,**不是主程序升版后自动更新的插件**: +- **修复“已有通过项却额外出现未验证项”**:兼容模型返回检查项时携带的、本程序生成的类别和时机展示标签,映射回原检查项。只匹配确切格式;正文、类别或时机冲突仍保留,失败项不被改成通过。旧任务结论不会自动重写。 +- **修复 Pro 笔记的截图路径**:保存、追加和更新笔记时,拒绝把实际位于 artifacts 子目录的文件误写为不存在的任务根目录路径,并返回真实候选位置,不自动猜选文件。截图回执要求沿用真实路径和 SHA256。 +- **完整响应才形成结果**:流式、非流式及结构化输出要求完整结束;失败、截断 JSON 或缺少终止事件不能进入工具执行或正式通过结论。请求重试保留已有工具证据。 +- **取消与超时可追踪**:区分用户停止、暂停、新指导、任务替换及预算到期;记录请求和尝试编号,区分单次请求、传输与最终验收总预算。 +- **统一进度与证据来源**:网页、MCP、Word/Excel 报告共用阶段和耗时信息;中途指导区分接收、领取与消费,证据注明实际 Android、PC、接口或文件来源及验收边界。 +- **接口结果更明确**:拒绝未知字段,MCP JSON 结果同时提供 structuredContent 和兼容文本;历史分页返回总数、下一页与真实存储步号,阶段更新按 id 合并并原子校验。 -- 本地候选 **0.38.0+artemis.1**:同步官方 Driver 0.38.0,补充任务栏、托盘及无标题原生菜单发现。 -- 根据实际键盘焦点和经验证的 PID、线程及窗口归属选择动作后观察窗口,返回实际来源。 -- 对固定上游源码增加已知 Shell/菜单无标题窗口的兼容补丁,重新构建 Driver、UIA helper 与 SDK DLL,记录来源和 SHA256。 -- 移除新版驱动已处理的滚动预缩放,避免坐标重复转换;保留最新截图编号、边界与窗口校验。 -- 先前验证记录包含 Go 测试、vet、构建,以及共享桌面锁内的菜单/任务栏 9 次调用和隔离浏览器表单 38 次调用。候选市场索引尚未发布,原失败业务全流程未重跑。 +## 网页体验 -## 验证与交付范围 +- 顶部太阳/月亮按钮切换亮暗主题,首次跟随系统并记住浏览器选择;首页、工作区、配置、插件和报告共用主题。 +- 计划、笔记和报告支持 Markdown 表格、列对齐、嵌套列表、任务清单、引用、链接、图片、删除线与代码块,保留转义和链接安全检查。 +- 工具和动作卡片允许选择、复制内容;RESULT 默认折叠为短预览和字符数,展开后限高滚动并支持复制全文。显示折叠不截断模型收到的工具证据。 +- 插件窗口状态和控件数据采用摘要及分组展示,需要时展开原始结果。 -升版前、撤回次数约束后的当前源码已完成:默认后端 **3063 项通过、2 项跳过、8 项排除**,保留 102 条现有 Pillow 弃用警告;专项 **38 项通过**;Ruff 格式与静态检查、质量基线、核心类型及差异空白检查通过。以上为上一轮实际执行的结果,非本次升版后重跑全量。 +## 配套 Cua 插件 -本次升版后实际完成: +[Cua 0.38.0+artemis.1](https://git.playones.com/chenmingxuan/Artemis-Plugins/releases/tag/cua-driver-v0.38.0+artemis.1) 在独立插件仓库发布,包含上游升级和 ARTEMIS 适配修复: -- 后端项目、UV 锁、前端项目、NPM 锁顶层与根包、本机发行元数据及运行时公开版本共七个读数均为 **2.2.0**。 -- `uv lock --check --offline` 通过;离线重建并更新本机 Artemis 可编辑安装,仅调整该包,其他依赖版本保留。 -- 启动导入、安装资源、状态路径及配置/源码打包相关测试 **44 项通过**。 -- 前端 ChromeHeadless **317 项通过**,生产构建通过。 -- 本轮版本文件及文档的差异空白、相对链接检查通过。未重复执行升版前已通过的后端全量和质量门禁,不把上轮结果标作升版后重跑。 +- 固定官方 Driver 0.38.0、源码提交和官方 ZIP SHA256。 +- 补充任务栏、托盘及无标题原生菜单发现;根据真实键盘焦点以及 PID、线程和窗口归属决定动作后观察目标。 +- 对固定上游源码应用已知 Shell/菜单无标题窗口兼容补丁,重新构建 Driver、UIA helper 和 SDK DLL;包内包含补丁、来源与二进制 SHA256。 +- 移除新版驱动已经处理的滚动预缩放,避免重复坐标转换;保留新截图编号、边界与窗口检查。 +- 沿用宿主桌面锁、证据、录像和子进程回收。前台输入会占用桌面;此前反馈的显示光标与系统鼠标偶发分离仍待复现,本版不宣称已解决。 -本轮未调用付费模型、操作设备、修改历史任务或重启后台;现有原业务、真实多 DPI、多显示器、长任务和压力场景不能据此视为全部通过。 +## 验证结果与边界 -更新安装时由现有安装/升级流程同步包元数据和网页构建;运行中的后台需正常重启后加载新代码。主程序升级不会自动升级或授权插件。 +本次发布代码的验证结果: + +| 检查 | 结果 | +| --- | --- | +| 默认 Python 测试 | 3189 通过、2 跳过、8 按标记排除;102 条已有 Pillow 弃用警告 | +| Checker 专项 | 74 通过,覆盖展示标签兼容、缺项、冲突和失败保留 | +| 远程 Python 客户端 | 24 通过 | +| Angular ChromeHeadless | 326 通过,生产构建通过 | +| Python 质量门禁 | Ruff 静态与格式、质量基线、核心类型检查通过 | +| 依赖与差异 | UV 锁离线检查、Git 差异空白检查通过 | +| Cua 确定性检查 | Go 测试、vet 与构建通过;源码、补丁和包内二进制来源分别核对 | + +10 月 26 日集中实测的 **9 项正式用例全部通过**,覆盖 Android 三轮输入清空与暂停恢复、仅 PC,以及两轮跨端传递;同时完成 313 项针对性检查。该组使用 `gpt-6-luna / high`,观察到任务按序执行。另一次准备探索因超出指定页面范围而失败,单独保留,未计入 9 项通过。 + +后续排队状态、笔记路径与验收匹配修复另做新任务验证:实际观察到 running/pending 顺序切换;无效笔记路径被拒绝,真实文件和哈希可核对。最后的 Android Pro 验收任务 **3 项通过、无重复未验证项**,11 次 `gpt-6-luna / high` 请求全部完成、无重试和中途指导。先前曾未判定的任务保留原结论,没有用新解析覆盖历史。 + +Cua 此版本另有菜单/任务栏 9 次调用与隔离 Brave 表单 38 次调用验证。上述覆盖不等于原失败业务全部复跑,也未覆盖长时间压力、强制真实断连、多设备及全部多屏/DPI 组合。仅 PC 的无手机在线准入另由确定性测试和页面检查覆盖。 + +## 安装与升级 + +1. 等待当前任务结束,运行 `update.bat`,国内镜像运行 `update-cn.bat`。升级流程会停止本项目进程并同步环境。 +2. 完成后运行 `start.bat` 或 `start-cn.bat`,由新版后台加载代码并构建网页。 +3. 如需新电脑控制能力,在插件市场单独更新至 Cua `0.38.0+artemis.1`,确认授权后再创建任务。**主程序升级不会自动升级或授权插件**,已创建任务固定原插件版本。 + +发布附件为 `artemis-eapil-2.2.0-source.zip` 与 `SHA256SUMS.txt`。源码包通过仓库打包脚本生成,保留浅 Git 更新能力;不包含本机配置、凭据、历史任务、依赖目录及预构建网页。 diff --git a/mcp_server/catalog.json b/mcp_server/catalog.json index 9da0dc4..53aedf8 100644 --- a/mcp_server/catalog.json +++ b/mcp_server/catalog.json @@ -178,7 +178,7 @@ }, { "name": "mobile_manage_task", - "description": "Manages the lifecycle and retrieves the status of a background mobile automation task.\n\nThis is your primary diagnostic and control tool for tasks started by\n`mobile_run_task` or the web workspace. Poll with reasonable intervals while\nfollowing a running task; use request diagnostics for unexplained waits.\nYou can also steer a runner that is stuck or off-track, or stop a task.\n\n### Actions\n- lookup: Resolve a submission by request_id without starting or resubmitting it.\n- deletion_preview / delete: Show affected session IDs, then delete only when\n the user requested it (user_confirmed=true). Active tasks cannot be deleted.\n An exploration deletes its conversation runs, preserving independent reruns.\n- review: Record ONLY the user's explicitly stated passed/failed decision with\n review_reason and user_confirmed=true. Never label the agent's verdict as human.\n- **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/\n 'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device\n setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when\n the run declared verification check items: machine-readable\n passed/failed/inconclusive/unchecked counts plus failed-item details —\n no need to parse report prose), `test_result` (Flash and Pro: status\n passed/failed/inconclusive/blocked, reason and evidence; completed only\n means execution ended), and `progress` (Flash:\n current turn, latest thought/action — intervene if turns climb without\n progress or the thought indicates it is stuck; Pro: the active task plan\n — check it still aligns with your goal).\n- **'pause'**: Requests a pause, returning `pausing` until the runner releases\n the device. Pro can interrupt a pending model response or read-only diagnostic;\n a device action already in progress must finish safely first. Check status\n for `task_paused` before assuming the device is free.\n- **'resume'**: Queues a paused task for its device again; optional `instruction`\n provides guidance before the next action.\n- **'inject_instruction'**: Injects real-time guidance mid-flight when the\n subagent errs, stalls, or loops. An in-flight model response or read-only\n helper is cancelled immediately, then the runner observes the device again\n and consumes the new guidance. An already-started device action finishes\n safely before the runner changes direction. Requires\n `instruction` (unless `release_loop=True`). To end a continuous\n monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass\n `release_loop=True` — this is the only signal that unlocks the\n milestone's completion; natural-language \"please stop\" phrasing in\n `instruction` is NOT interpreted as a stop signal.\n- **'guidance_history'**: Lists submitted instructions with `pending` or\n `consumed` status; a submitted instruction is not proof it was applied.\n- **'stop'**: Forcefully terminates the subagent, immediately halting\n device interactions and releasing the device. Use when the task is done,\n irreparably broken, or running out of control. Prefer\n `inject_instruction` with `release_loop=True` when a monitoring task\n should wind down cleanly instead of being killed.\n\nArgs:\n action: `\"status\"`, `\"pause\"`, `\"resume\"`, `\"inject_instruction\"`,\n `\"guidance_history\"`, or `\"stop\"`.\n trace_id: The task's session identifier from `mobile_run_task` or the web UI.\n instruction: Guidance string; required for `inject_instruction`\n (optional when `release_loop=True`), omit otherwise.\n release_loop: With `inject_instruction`: explicit user stop signal\n that authorizes the subagent to complete continuous monitoring\n loops and finish the task gracefully.\n", + "description": "Manages the lifecycle and retrieves the status of a background mobile automation task.\n\nThis is your primary diagnostic and control tool for tasks started by\n`mobile_run_task` or the web workspace. Poll with reasonable intervals while\nfollowing a running task; use request diagnostics for unexplained waits.\nYou can also steer a runner that is stuck or off-track, or stop a task.\n\n### Actions\n- lookup: Resolve a submission by request_id without starting or resubmitting it.\n- deletion_preview / delete: Show affected session IDs, then delete only when\n the user requested it (user_confirmed=true). Active tasks cannot be deleted.\n An exploration deletes its conversation runs, preserving independent reruns.\n- review: Record ONLY the user's explicitly stated passed/failed decision with\n review_reason and user_confirmed=true. Never label the agent's verdict as human.\n- **'status'**: Returns `trace_id`, `status` ('pending'/'running'/'pausing'/'task_paused'/\n 'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device\n setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when\n the run declared verification check items: machine-readable\n passed/failed/inconclusive/unchecked counts plus failed-item details —\n no need to parse report prose), `test_result` (Flash and Pro: status\n passed/failed/inconclusive/blocked, reason and evidence; completed only\n means execution ended), and `progress` (Flash:\n current turn, latest thought/action — intervene if turns climb without\n progress or the thought indicates it is stuck; Pro: the active task plan\n — check it still aligns with your goal).\n- **'pause'**: Requests a pause, returning `pausing` until the runner releases\n the device. Pro can interrupt a pending model response or read-only diagnostic;\n a device action already in progress must finish safely first. Check status\n for `task_paused` before assuming the device is free.\n- **'resume'**: Queues a paused task for its device again; optional `instruction`\n provides guidance before the next action.\n- **'inject_instruction'**: Injects real-time guidance mid-flight when the\n subagent errs, stalls, or loops. An in-flight model response or read-only\n helper is cancelled immediately, then the runner observes the device again\n and consumes the new guidance. An already-started device action finishes\n safely before the runner changes direction. Requires\n `instruction` (unless `release_loop=True`). To end a continuous\n monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass\n `release_loop=True` — this is the only signal that unlocks the\n milestone's completion; natural-language \"please stop\" phrasing in\n `instruction` is NOT interpreted as a stop signal.\n- **'guidance_history'**: Lists submitted instructions with `pending` or\n `consumed` status; a submitted instruction is not proof it was applied.\n- **'stop'**: Forcefully terminates the subagent, immediately halting\n device interactions and releasing the device. Use when the task is done,\n irreparably broken, or running out of control. Prefer\n `inject_instruction` with `release_loop=True` when a monitoring task\n should wind down cleanly instead of being killed.\n\nArgs:\n action: `\"status\"`, `\"pause\"`, `\"resume\"`, `\"inject_instruction\"`,\n `\"guidance_history\"`, or `\"stop\"`.\n trace_id: The task's session identifier from `mobile_run_task` or the web UI.\n instruction: Guidance string; required for `inject_instruction`\n (optional when `release_loop=True`), omit otherwise.\n release_loop: With `inject_instruction`: explicit user stop signal\n that authorizes the subagent to complete continuous monitoring\n loops and finish the task gracefully.\n", "inputSchema": { "properties": { "action": { @@ -257,7 +257,7 @@ }, { "name": "mobile_run_task", - "description": "Submit a Flash/Pro Android, computer or mixed task to the user-started ARTEMIS service.\n\nDelegates a mobile workflow to a background agent. Non-blocking: returns\nimmediately with `trace_id` (for `mobile_manage_task` / `mobile_inspect_trace`),\n`device_serial`, `stdout_log`/`stderr_log` paths, and `notes_dir` (Pro only).\nCompletion notifications depend on the caller's configured notification backend;\nconversation_id alone does not guarantee the agent client receives a wakeup.\n\n### Model selection (routing)\n- **Flash** (default, preferred): simple, deterministic tasks with a clear\n UI path. No step cap by default (history is compressed, and the agent\n can recall earlier steps and query the session recording), but no ADB\n shell, no pre-execution safety net, no persistent plan or notes, and\n no midway checkpoints or persistent written notes. An exit-only read-only\n Checker judges the prompt's expectations and returns test_result;\n execution completion is separate from test pass. Unsuitable for exploration,\n multi-branch recovery, structured monitoring/polling, or tasks that\n must report back large amounts of detail.\n- **Pro**: complex, exploratory, or multi-branch tasks; continuous\n monitoring/polling; tasks needing ADB shell, system logs, verified\n checkpoints, multi-step planning, or detailed written output (via notes\n and `expected_output_desc`).\n\nFor interactive case design use mobile_exploration: user guidance, demonstration\nanalysis and reusable case generation. It has independent model settings.\nInference time varies by model, network and device; do not subtract a fixed\nper-step estimate from required waits. Deterministic test code needs explicit\ntiming and state-based waits verified against the real application.\n\n### Follow-up\n1. Poll mobile_manage_task at reasonable intervals (usually 30–60 seconds)\n while following a task; do not assume an unconfigured background notification.\n2. Read test_result and view_report after completion. Inspect relevant request,\n log or step evidence for failures and unexplained waits.\n3. Read Pro notes when its plan or detailed output is relevant to the user.\n\nReuse request_id for retries of the same submission; lookup with\nmobile_manage_task(action=\"lookup\", request_id=...). Never repeat a task\nwith a fresh ID merely because its admission reply was lost.\n\nArgs:\n request_id: Stable caller-generated ID (letters, numbers, _ or -, up to 100).\n task_desc: Clear, self-contained task description with all context,\n exact UI actions, and termination conditions (execution is one-shot).\n For Pro, may also specify what the subagent must record in its notes.\n conversation_id: Optional conversation ID used to route the completion\n wakeup notification back to the caller. Omit if unknown.\n model: `\"Flash\"` or `\"Pro\"` — see model selection above.\n locked_app_package: Optional package name to lock execution to; the\n agent auto-launches it and restricts actions to that app.\n app_path: Optional local APK path to install before running.\n expected_output_desc: Optional, Pro only. If set, a summarization agent\n writes a report to `output.md` in `notes_dir`. Ignored for Flash.\n device_serial: Optional device serial (e.g. \"emulator-5554\") to bind\n execution to a specific device; distinct devices run concurrently.\n If omitted, an available device is selected automatically. When\n several devices are attached, confirm the target with the user first\n (`adb devices -l` lists serials and authorization states).\n verification_level: Optional, Pro only. Coarse Checker preset: `\"off\"`\n (no audit; the Operator self-reports), `\"final\"` (one exit review\n against the goal, the default), `\"checkpoints\"` (every plan\n checkpoint is verified + exit review), `\"strict\"` (checkpoints with\n a larger repair budget; a failed assert halts the run). Ignored for\n Flash.\n explorer_mode: Optional, Pro only. Explorer perception version used\n by the Operator: `\"flash\"` (1-shot detection, the default),\n `\"pro\"` (3-turn ReAct), `\"ultra\"` (deep pixel reasoning; slowest).\n Ignored for Flash.\n runtime_overrides: Optional TaskOptions JSON for this task only. Supports\n execution_target ('android', 'computer', 'mixed') and plugins\n ([{id, profile}] references to saved authorized configurations; never secrets).\n Omitted plugins selects matching enabled defaults; [] disables plugins.\n Default Android tasks can use mixed extensions on demand without changing targets.\n Computer tasks require an observation plugin, not an Android device.\n Query mobile_workspace first; installation/authorization is in the plugin page.\n timeout_seconds (total execution, default 1800; excludes queue/pause),\n observation_timeout_seconds (60), observation_max_attempts (3),\n device_command_timeout_seconds (10), action_failure_attempts (3),\n worker_cleanup_timeout_seconds (30),\n model (API model name), reasoning_effort, request timeout/retries,\n record_video and video analysis. recording is a partial archive-quality object:\n codec (auto/h264/h265), computer_scope (screen/window), max_long_edge,\n fps, h264_kbps, h265_kbps; it does not enable recording on its own.\n Unknown fields and credentials are rejected.\n", + "description": "Submit a Flash/Pro Android, computer or mixed task to the user-started ARTEMIS service.\n\nDelegates a mobile workflow to a background agent. Non-blocking: returns\nimmediately with `trace_id` (for `mobile_manage_task` / `mobile_inspect_trace`),\n`device_serial`, `stdout_log`/`stderr_log` paths, and `notes_dir` (Pro only).\nAdmission normally returns `status=\"pending\"`; it does not mean the worker\nis running. A newer worker state is returned when already available.\nCompletion notifications depend on the caller's configured notification backend;\nconversation_id alone does not guarantee the agent client receives a wakeup.\n\n### Model selection (routing)\n- **Flash** (default, preferred): simple, deterministic tasks with a clear\n UI path. No step cap by default (history is compressed, and the agent\n can recall earlier steps and query the session recording), but no ADB\n shell, no pre-execution safety net, no persistent plan or notes, and\n no midway checkpoints or persistent written notes. An exit-only read-only\n Checker judges the prompt's expectations and returns test_result;\n execution completion is separate from test pass. Unsuitable for exploration,\n multi-branch recovery, structured monitoring/polling, or tasks that\n must report back large amounts of detail.\n- **Pro**: complex, exploratory, or multi-branch tasks; continuous\n monitoring/polling; tasks needing ADB shell, system logs, verified\n checkpoints, multi-step planning, or detailed written output (via notes\n and `expected_output_desc`).\n\nFor interactive case design use mobile_exploration: user guidance, demonstration\nanalysis and reusable case generation. It has independent model settings.\nInference time varies by model, network and device; do not subtract a fixed\nper-step estimate from required waits. Deterministic test code needs explicit\ntiming and state-based waits verified against the real application.\n\n### Follow-up\n1. Poll mobile_manage_task at reasonable intervals (usually 30–60 seconds)\n while following a task; do not assume an unconfigured background notification.\n2. Read test_result and view_report after completion. Inspect relevant request,\n log or step evidence for failures and unexplained waits.\n3. Read Pro notes when its plan or detailed output is relevant to the user.\n\nReuse request_id for retries of the same submission; lookup with\nmobile_manage_task(action=\"lookup\", request_id=...). Never repeat a task\nwith a fresh ID merely because its admission reply was lost.\n\nArgs:\n request_id: Stable caller-generated ID (letters, numbers, _ or -, up to 100).\n task_desc: Clear, self-contained task description with all context,\n exact UI actions, and termination conditions (execution is one-shot).\n For Pro, may also specify what the subagent must record in its notes.\n conversation_id: Optional conversation ID used to route the completion\n wakeup notification back to the caller. Omit if unknown.\n model: `\"Flash\"` or `\"Pro\"` — see model selection above.\n locked_app_package: Optional package name to lock execution to; the\n agent auto-launches it and restricts actions to that app.\n app_path: Optional local APK path to install before running.\n expected_output_desc: Optional, Pro only. If set, a summarization agent\n writes a report to `output.md` in `notes_dir`. Ignored for Flash.\n device_serial: Optional device serial (e.g. \"emulator-5554\") to bind\n execution to a specific device; tasks share the global serial queue.\n If omitted, an available device is selected automatically. When\n several devices are attached, confirm the target with the user first\n (`adb devices -l` lists serials and authorization states).\n verification_level: Optional, Pro only. Coarse Checker preset: `\"off\"`\n (no audit; the Operator self-reports), `\"final\"` (one exit review\n against the goal, the default), `\"checkpoints\"` (every plan\n checkpoint is verified + exit review), `\"strict\"` (checkpoints with\n a larger repair budget; a failed assert halts the run). Ignored for\n Flash.\n explorer_mode: Optional, Pro only. Explorer perception version used\n by the Operator: `\"flash\"` (1-shot detection, the default),\n `\"pro\"` (3-turn ReAct), `\"ultra\"` (deep pixel reasoning; slowest).\n Ignored for Flash.\n runtime_overrides: Optional TaskOptions JSON for this task only. Supports\n execution_target ('android', 'computer', 'mixed') and plugins\n ([{id, profile}] references to saved authorized configurations; never secrets).\n Omitted plugins selects matching enabled defaults; [] disables plugins.\n Default Android tasks can use mixed extensions on demand without changing targets.\n PC-only tasks use {\"execution_target\": \"computer\"}; no Android device or\n emulator needs to be online. Computer tasks require an observation plugin.\n This matches the web launcher's PC-only checkbox and batch task_config JSON.\n Query mobile_workspace first; installation/authorization is in the plugin page.\n timeout_seconds (total execution, default 1800; excludes queue/pause),\n observation_timeout_seconds (60), observation_max_attempts (3),\n device_command_timeout_seconds (10), action_failure_attempts (3),\n worker_cleanup_timeout_seconds (30),\n model (API model name), reasoning_effort, request timeout/retries,\n record_video and video analysis. recording is a partial archive-quality object:\n codec (auto/h264/h265), computer_scope (screen/window), max_long_edge,\n fps, h264_kbps, h265_kbps; it does not enable recording on its own.\n Unknown fields and credentials are rejected.\n", "inputSchema": { "properties": { "task_desc": { diff --git a/mcp_server/tools/task_manager.py b/mcp_server/tools/task_manager.py index a293605..6ae7273 100644 --- a/mcp_server/tools/task_manager.py +++ b/mcp_server/tools/task_manager.py @@ -277,7 +277,7 @@ def mobile_manage_task( An exploration deletes its conversation runs, preserving independent reruns. - review: Record ONLY the user's explicitly stated passed/failed decision with review_reason and user_confirmed=true. Never label the agent's verdict as human. - - **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/ + - **'status'**: Returns `trace_id`, `status` ('pending'/'running'/'pausing'/'task_paused'/ 'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when the run declared verification check items: machine-readable diff --git a/mcp_server/tools/task_runner.py b/mcp_server/tools/task_runner.py index 043b50d..b81fe7f 100644 --- a/mcp_server/tools/task_runner.py +++ b/mcp_server/tools/task_runner.py @@ -274,6 +274,8 @@ def mobile_run_task( Delegates a mobile workflow to a background agent. Non-blocking: returns immediately with `trace_id` (for `mobile_manage_task` / `mobile_inspect_trace`), `device_serial`, `stdout_log`/`stderr_log` paths, and `notes_dir` (Pro only). + Admission normally returns `status="pending"`; it does not mean the worker + is running. A newer worker state is returned when already available. Completion notifications depend on the caller's configured notification backend; conversation_id alone does not guarantee the agent client receives a wakeup. @@ -323,7 +325,7 @@ def mobile_run_task( expected_output_desc: Optional, Pro only. If set, a summarization agent writes a report to `output.md` in `notes_dir`. Ignored for Flash. device_serial: Optional device serial (e.g. "emulator-5554") to bind - execution to a specific device; distinct devices run concurrently. + execution to a specific device; tasks share the global serial queue. If omitted, an available device is selected automatically. When several devices are attached, confirm the target with the user first (`adb devices -l` lists serials and authorization states). @@ -342,7 +344,9 @@ def mobile_run_task( ([{id, profile}] references to saved authorized configurations; never secrets). Omitted plugins selects matching enabled defaults; [] disables plugins. Default Android tasks can use mixed extensions on demand without changing targets. - Computer tasks require an observation plugin, not an Android device. + PC-only tasks use {"execution_target": "computer"}; no Android device or + emulator needs to be online. Computer tasks require an observation plugin. + This matches the web launcher's PC-only checkbox and batch task_config JSON. Query mobile_workspace first; installation/authorization is in the plugin page. timeout_seconds (total execution, default 1800; excludes queue/pause), observation_timeout_seconds (60), observation_max_attempts (3), @@ -373,6 +377,14 @@ def mobile_run_task( return _run_task(trace_id=str(uuid.uuid4()), **parameters) +def _daemon_task_status(trace_id: str, task: dict[str, Any]) -> str: + """Prefer a worker's newer state without writing back an admission snapshot.""" + current = (trace_store.read_status(trace_id) or {}).get("status") + if current and current != "pending": + return current + return task.get("status") or "pending" + + def _run_task( *, trace_id: str, @@ -406,6 +418,10 @@ def _run_task( # plain tool error rather than a failed trace on disk. verification_level, explorer_mode = _normalize_pro_tuning(verification_level, explorer_mode) + execution_target = (runtime_overrides or {}).get("execution_target", "android") + if execution_target == "computer": + device_serial = "computer:local" + # 2. Initialize the trace store directory and status.json trace_store.init_trace( trace_id=trace_id, @@ -419,7 +435,6 @@ def _run_task( # authorized. Rejecting here prevents the task from silently running on a # different device than the caller asked for. Runs after init_trace so the # rejection carries a trace_id like every other response of this tool. - execution_target = (runtime_overrides or {}).get("execution_target", "android") if device_serial and execution_target != "computer": rejection = _validate_device_serial(device_serial) if rejection: @@ -431,6 +446,8 @@ def _run_task( try: is_running, base_url = ensure_daemon_running(timeout=2.0, wait_ready=True) if is_running: + # Set admission state before dispatch; after this the daemon owns it. + trace_store.update_trace_status(trace_id, "pending", device_serial=device_serial) resp = submit_task_to_daemon( goal=task_desc, profile=canonical_model.lower(), @@ -460,16 +477,14 @@ def _run_task( "message": f"Task rejected by Artemis Daemon: {rejection_error}", } if resp and resp.get("tasks"): - assigned_sid = resp["tasks"][0].get("session_id", trace_id) - trace_store.update_trace_status( - assigned_sid, "running", device_serial=device_serial - ) + task = resp["tasks"][0] + assigned_sid = task.get("session_id", trace_id) notes_dir = str(trace_store.get_trace_notes_dir(assigned_sid)) stdout_log = str(trace_store.get_trace_stdout_log_path(assigned_sid)) stderr_log = str(trace_store.get_trace_stderr_log_path(assigned_sid)) return { "trace_id": assigned_sid, - "status": "running", + "status": _daemon_task_status(assigned_sid, task), "message": ( f"Autonomous task '{task_desc}' enqueued via unified Artemis Daemon.\n" f"Trace ID: {assigned_sid}\n" @@ -501,12 +516,9 @@ def _run_task( break for q in queued_tasks: if str(q.get("session_id")) == str(trace_id): - trace_store.update_trace_status( - trace_id, "running", device_serial=device_serial - ) return { "trace_id": trace_id, - "status": "running", + "status": _daemon_task_status(trace_id, q), "message": ( f"Autonomous task '{task_desc}' enqueued via unified Artemis Daemon.\n" f"Trace ID: {trace_id}\n" diff --git a/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk b/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk index 1fc50a0..34eced6 100644 Binary files a/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk and b/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk differ diff --git a/packages/artemis-accessibility-helper/app/build.gradle.kts b/packages/artemis-accessibility-helper/app/build.gradle.kts index f21f28b..1f8143a 100644 --- a/packages/artemis-accessibility-helper/app/build.gradle.kts +++ b/packages/artemis-accessibility-helper/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "com.artemis.helper" minSdk = 24 targetSdk = 35 - versionCode = 8 - versionName = "1.2.2" + versionCode = 9 + versionName = "1.2.3" } buildTypes { diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java index 9dae071..8e97c40 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java @@ -796,10 +796,16 @@ public final class HierarchyDumper { for (RawRootEntry entry : roots) { try { AccessibilityNodeInfo focused = entry.root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT); - if (focused != null) { + // The IME window can have a focused, non-editable container + // ahead of the app's focused field in window layer order. + if (focused != null && focused.isEditable() + && focused.isEnabled() && focused.isVisibleToUser()) { found = focused; return found; } + if (focused != null && focused != entry.root) { + safeRecycle(focused); + } } catch (Throwable ignored) {} } for (RawRootEntry entry : roots) { diff --git a/packages/artemis-accessibility-helper/helper_manifest.json b/packages/artemis-accessibility-helper/helper_manifest.json index c649826..4e9267c 100644 --- a/packages/artemis-accessibility-helper/helper_manifest.json +++ b/packages/artemis-accessibility-helper/helper_manifest.json @@ -1,7 +1,7 @@ { "package": "com.artemis.helper", - "version_code": 8, - "version_name": "1.2.2", - "sha256": "1d2b87f4bde9a4022a923519656b9229cedc6258830149842ac33be768866bb4", - "built_at": "2026-10-25T09:57:42+00:00" + "version_code": 9, + "version_name": "1.2.3", + "sha256": "f25f4b6b173049def6601953d8aa43779f3e5c5775d9a74f46dff7e93b731123", + "built_at": "2026-10-26T13:49:10+00:00" } diff --git a/packages/artemis-client/DESIGN.md b/packages/artemis-client/DESIGN.md index 126d14b..68e0303 100644 --- a/packages/artemis-client/DESIGN.md +++ b/packages/artemis-client/DESIGN.md @@ -23,6 +23,10 @@ normalization, and result models. Device discovery, task scheduling, concurrency control, model selection, execution, logs, videos, and artifact generation all belong to the server. +Artemis 2.2.0 executes one task at a time across computer and Android targets. +Legacy concurrency options remain accepted for compatibility, but cannot enable +parallel execution. The client does not implement a separate scheduler. + The distribution is named `artemis-client`, while the Python import is `artemis_client`. This prevents it from shadowing the full runtime's top-level `artemis` package. The full runtime can depend on this package later and @@ -39,6 +43,12 @@ session database, the client reconstructs its state from `queue` and Experimental parameters live in `options`. Older servers ignore unknown fields, while newer servers can consume them without requiring an immediate SDK release. +On the current host, `options` maps to the shared `runtime_overrides` TaskOptions +object rather than an unknown top-level `options` field. The first-class +`execution_target` argument is serialized there too. `computer` omits saved +Android serials; the host owns plugin selection, desktop observation, resource +locks and execution. The client never discovers Android for a PC-only submission. + ### Guided exploration in 2.0 `ArtemisClient.exploration()` uses the existing `/api/explorations` API shared diff --git a/packages/artemis-client/README.md b/packages/artemis-client/README.md index daa8400..e181fee 100644 --- a/packages/artemis-client/README.md +++ b/packages/artemis-client/README.md @@ -7,6 +7,9 @@ ADB, device-driver, agent, LLM, or image-processing code. Runtime dependencies: **zero**. HTTP, JSON, and asynchronous adapters use only the Python standard library. +Artemis 2.2.0 queues computer and Android tasks for serial execution. Legacy +concurrency options cannot enable parallel execution on that server version. + ## Installation Install from a local checkout: @@ -44,6 +47,26 @@ and bearer-token authentication and exposes only task-related APIs. ## Usage +PC-only tasks do not need Android or an emulator online. Enable and authorize a +computer observation plugin on the host first, then use the same target as the +web launcher's “仅 PC” checkbox: + +```python +handle = await client.submit( + "Open the desktop browser and inspect the requested page", + profile="flash", # "pro" is also supported + execution_target="computer", +) +``` + +`run()` accepts the same argument. For task-like objects, use +`Task(goal="Check desktop", execution_target="computer")` from the full runtime +and pass it to `client.run_task()`. A saved Android device on the client is ignored +for PC-only tasks. Per-task `options` are sent as the server's `runtime_overrides`, +for example `options={"execution_target":"computer","plugins":[{"id":"cua-driver","profile":"default"}]}`. +Plugin IDs must exist on the host; omitting `plugins` selects enabled authorized +defaults for the target. Mixed tasks still require Android. + For interactive case design, use `client.exploration` with the same actions as the web workspace and `mobile_exploration`. Use a stable request ID when retrying a write: diff --git a/packages/artemis-client/src/artemis_client/__init__.py b/packages/artemis-client/src/artemis_client/__init__.py index 5fa4817..2f15408 100644 --- a/packages/artemis-client/src/artemis_client/__init__.py +++ b/packages/artemis-client/src/artemis_client/__init__.py @@ -8,7 +8,7 @@ """Public API for the lightweight Artemis remote client.""" -from artemis_client.client import ArtemisClient +from artemis_client.client import ArtemisClient, ExecutionTarget from artemis_client.errors import ( ApiError, ArtemisClientError, @@ -33,6 +33,7 @@ __all__ = [ "Capabilities", "ConflictError", "Device", + "ExecutionTarget", "JsonTransport", "NetworkError", "NotFoundError", diff --git a/packages/artemis-client/src/artemis_client/client.py b/packages/artemis-client/src/artemis_client/client.py index a897f1b..52850cc 100644 --- a/packages/artemis-client/src/artemis_client/client.py +++ b/packages/artemis-client/src/artemis_client/client.py @@ -39,6 +39,8 @@ VERIFICATION_LEVELS: tuple[str, ...] = ("off", "final", "checkpoints", "strict") #: Pro-profile Explorer perception versions accepted by ``/api/run`` (``explorer_mode``). ExplorerMode = Literal["flash", "pro", "ultra"] EXPLORER_MODES: tuple[str, ...] = ("flash", "pro", "ultra") +ExecutionTarget = Literal["android", "computer", "mixed"] +EXECUTION_TARGETS: tuple[str, ...] = ("android", "computer", "mixed") def _normalize_choice(value: str | None, name: str, choices: tuple[str, ...]) -> str | None: @@ -82,7 +84,7 @@ class ArtemisClient: device_id: str | None = None, device_serial: str | None = None, default_profile: Literal["flash", "pro"] = "flash", - concurrency_mode: str = "per_device", + concurrency_mode: str = "global", max_concurrency: int | None = None, standalone: bool = False, ) -> None: @@ -187,6 +189,7 @@ class ArtemisClient: task_id: str | None = None, verification_level: VerificationLevel | None = None, explorer_mode: ExplorerMode | None = None, + execution_target: ExecutionTarget | None = None, options: Mapping[str, Any] | None = None, ) -> TaskHandle: """Submit one task and return immediately after scheduler admission. @@ -197,7 +200,9 @@ class ArtemisClient: ``strict``: how much the Checker audits a Pro run) and ``explorer_mode`` (``flash`` | ``pro`` | ``ultra``: the Pro Operator's perception depth) are Pro-only tuning knobs; the Flash profile ignores - them. Experimental, forward-compatible fields belong in ``options``. + them. ``execution_target="computer"`` runs only on the host PC without + Android discovery. Per-task ``options`` use the host's TaskOptions schema + and are sent as ``runtime_overrides`` (including plugin selections). """ normalized_goal = goal.strip() if not normalized_goal: @@ -206,6 +211,18 @@ class ArtemisClient: verification_level, "verification_level", VERIFICATION_LEVELS ) resolved_mode = _normalize_choice(explorer_mode, "explorer_mode", EXPLORER_MODES) + runtime_overrides = dict(options) if options is not None else {} + resolved_target = _normalize_choice( + execution_target + if execution_target is not None + else runtime_overrides.get("execution_target"), + "execution_target", + EXECUTION_TARGETS, + ) + if resolved_target is not None: + runtime_overrides["execution_target"] = resolved_target + if resolved_target == "computer": + runtime_overrides.pop("device_serial", None) if task_id is None: resolved_task_id = str(uuid.uuid4()) else: @@ -214,7 +231,9 @@ class ArtemisClient: except (AttributeError, TypeError, ValueError) as exc: raise ValueError("task_id must be a valid UUID string") from exc resolved_profile = profile or self.default_profile - resolved_device = device_serial or self.device_serial + resolved_device = ( + None if resolved_target == "computer" else device_serial or self.device_serial + ) payload: dict[str, Any] = { "goal": normalized_goal, "profile": resolved_profile, @@ -230,7 +249,9 @@ class ArtemisClient: "conversation_id": conversation_id, "verification_level": resolved_level, "explorer_mode": resolved_mode, - "options": dict(options) if options is not None else None, + "runtime_overrides": runtime_overrides + if options is not None or runtime_overrides + else None, } payload.update({key: value for key, value in optional_values.items() if value is not None}) @@ -307,6 +328,7 @@ class ArtemisClient: task_id: str | None = None, verification_level: VerificationLevel | None = None, explorer_mode: ExplorerMode | None = None, + execution_target: ExecutionTarget | None = None, options: Mapping[str, Any] | None = None, timeout: float = 1800.0, poll_interval: float | None = None, @@ -324,6 +346,7 @@ class ArtemisClient: task_id=task_id, verification_level=verification_level, explorer_mode=explorer_mode, + execution_target=execution_target, options=options, ) return await self.wait_for_task( @@ -342,6 +365,7 @@ class ArtemisClient: "device_serial": getattr(task, "device_serial", None) or getattr(task, "device_id", None), "locked_app_package": getattr(task, "locked_package", None), + "execution_target": getattr(task, "execution_target", None), } values.update(overrides) return await self.run(goal, **values) diff --git a/packages/artemis-client/tests/test_client.py b/packages/artemis-client/tests/test_client.py index a971efc..120797e 100644 --- a/packages/artemis-client/tests/test_client.py +++ b/packages/artemis-client/tests/test_client.py @@ -18,6 +18,7 @@ from artemis_client import ( NotFoundError, ProtocolError, TaskRejectedError, + TaskResult, TaskTimeoutError, ) @@ -105,7 +106,50 @@ class ArtemisClientTests(unittest.IsolatedAsyncioTestCase): assert body is not None self.assertEqual(body["session_id"], task_id) self.assertEqual(body["ingress"], "python_sdk") - self.assertEqual(body["options"], {"record_video": True}) + self.assertEqual(body["runtime_overrides"], {"record_video": True}) + + async def test_pc_submission_ignores_saved_phone_and_uses_shared_options(self) -> None: + self.client.device_serial = "offline-phone" + options = {"device_serial": "old-phone", "plugins": [{"id": "cua", "profile": "default"}]} + self.transport.add( + "POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]} + ) + await self.client.submit("Check desktop", execution_target="computer", options=options) + body = self.transport.calls[-1][2] + assert body is not None + self.assertNotIn("device_serial", body) + self.assertEqual( + body["runtime_overrides"], + {"execution_target": "computer", "plugins": options["plugins"]}, + ) + self.assertEqual(options["device_serial"], "old-phone") + + async def test_pc_target_in_options_and_task_object_are_forwarded(self) -> None: + self.transport.add( + "POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]} + ) + await self.client.submit("Check desktop", options={"execution_target": "computer"}) + self.assertEqual( + self.transport.calls[-1][2]["runtime_overrides"], {"execution_target": "computer"} + ) + from unittest.mock import AsyncMock + from types import SimpleNamespace + + self.client.wait_for_task = AsyncMock( + return_value=TaskResult(task_id="pc", status="completed") + ) + self.transport.add( + "POST", "/api/run", {"tasks": [{"session_id": "pc", "status": "pending"}]} + ) + await self.client.run_task(SimpleNamespace(goal="Check PC", execution_target="computer")) + self.assertEqual( + self.transport.calls[-1][2]["runtime_overrides"], {"execution_target": "computer"} + ) + + async def test_invalid_execution_target_is_rejected_without_submission(self) -> None: + with self.assertRaisesRegex(ValueError, "execution_target"): + await self.client.submit("Check desktop", options={"execution_target": "phone"}) + self.assertEqual(self.transport.calls, []) async def test_submit_forwards_pro_tuning_knobs_normalised(self) -> None: task_id = "00000000-0000-4000-8000-000000000321" diff --git a/skills/artemis-use/references/plugins.md b/skills/artemis-use/references/plugins.md index a3f6791..9d2c685 100644 --- a/skills/artemis-use/references/plugins.md +++ b/skills/artemis-use/references/plugins.md @@ -17,6 +17,8 @@ 示例中的插件必须已安装;不要为解决缺插件而调用宿主私有电脑工具代替 ARTEMIS 执行。`computer` 无需 `device_serial`,`mixed` 同时需要手机与插件资源。批次每个用例的 `task_config` 使用相同字段,可混合不同配置和执行目标。 +网页首页的“仅 PC”勾选项与 `runtime_overrides.execution_target="computer"` 等价,Flash、Pro、探索均支持;Android 离线、锁屏或未安装 ADB 不阻止电脑任务。电脑任务仍需有效模型配置及至少一个已启用、已授权并提供画面观察的电脑插件。探索创建时使用 `task_config`,批量 Excel 的“任务配置(JSON)”可直接填写 `{"execution_target":"computer"}`。不要把 `pc_only` 或 `include_devices` 当作提交参数;后者只用于工作区发现。 + 未提供 `plugins` 表示使用该目标下已启用插件的授权默认配置;空数组明确不用插件。只提供插件 ID 和配置名称,版本由服务在入队时固定。安装、授权、连接凭据和资源保护解除由用户在插件页面处理。 插件超时/取消后先查任务状态和错误,不自动重放点击或配置写入。`mobile_workspace.blocked_resources` 非空时说明存在尚未确认停止的资源,不能声称暂停已安全完成。工具返回成功还需从后续画面或结构化读数验证业务结果。 diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py index 835cdfa..7bad91e 100644 --- a/tests/unit/admin_console/test_automation.py +++ b/tests/unit/admin_console/test_automation.py @@ -109,6 +109,20 @@ def test_invalid_archive_and_empty_template(): assert not task_import.parse_workbook(task_import.template())["valid"] +def test_pc_json_import_does_not_require_a_device_and_rejects_unknown_targets(): + data = workbook_bytes( + [ + ["PC-1", "电脑", "", "浏览器", "", "检查桌面", '{"execution_target":"computer"}'], + ["BAD-2", "", "", "", "", "检查页面", '{"execution_target":"pc"}'], + ] + ) + result = task_import.parse_workbook(data) + assert [item["row"] for item in result["errors"]] == [3] + options = result["tasks"][0]["task_config"] + assert options["execution_target"] == "computer" + assert options["device_serial"] is None + + def test_batch_archive_preparation_reports_progress_and_reuses_ready_file( client, isolated, monkeypatch ): diff --git a/tests/unit/admin_console/test_task_device_readiness.py b/tests/unit/admin_console/test_task_device_readiness.py index 3ecc9ef..d78cf04 100644 --- a/tests/unit/admin_console/test_task_device_readiness.py +++ b/tests/unit/admin_console/test_task_device_readiness.py @@ -20,6 +20,7 @@ import pytest from apps.admin_console.routers import tasks from apps.admin_console.schemas.task_schema import RunRequest +from apps.admin_console.services.task_import import TaskOptions from artemis.core.diagnostics.schema import ( ProbeCategory, ProbeResult, @@ -27,6 +28,28 @@ from artemis.core.diagnostics.schema import ( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("profile", ["flash", "pro"]) +async def test_pc_task_skips_android_submission_checks(monkeypatch, profile): + probe = AsyncMock(side_effect=AssertionError("PC submission cannot probe Android")) + validate = AsyncMock(side_effect=AssertionError("PC submission cannot enumerate Android")) + enqueue = AsyncMock(return_value={"status": "started", "tasks": []}) + monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", probe) + monkeypatch.setattr(tasks.device_pool, "validate_explicit_serial_async", validate) + monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue) + await tasks.run_task( + RunRequest( + goal="Check browser", + profile=profile, + device_serial="offline-phone", + runtime_overrides=TaskOptions(execution_target="computer"), + ) + ) + probe.assert_not_awaited() + validate.assert_not_awaited() + assert enqueue.call_args.kwargs["runtime_overrides"]["execution_target"] == "computer" + + @pytest.mark.asyncio async def test_run_task_rejects_locked_device(monkeypatch): locked_probe = ProbeResult( diff --git a/tests/unit/admin_console/test_task_queue_service.py b/tests/unit/admin_console/test_task_queue_service.py index 8db0ca0..b04834c 100644 --- a/tests/unit/admin_console/test_task_queue_service.py +++ b/tests/unit/admin_console/test_task_queue_service.py @@ -132,6 +132,39 @@ async def test_enqueued_task_keeps_its_adb_endpoint_snapshot(): assert target.lock_key == f"{original.identity}/emulator-5554" +@pytest.mark.asyncio +async def test_pc_queue_uses_desktop_resources_without_android_discovery(): + from artemis.plugins.models import PluginSelection + + pinned = [PluginSelection(id="cua", version="1.0.0")] + with ( + patch.object(TaskQueueService, "ensure_worker_running"), + patch("artemis.plugins.store.PluginStore.pin", return_value=pinned), + patch( + "artemis.plugins.store.PluginStore.reserve", return_value=["desktop:local"] + ) as reserve, + patch( + "artemis.runtime.device_pool.device_pool.select_device_async", new_callable=AsyncMock + ) as select, + patch( + "artemis.runtime.device_pool.device_pool.validate_explicit_serial_async", + new_callable=AsyncMock, + ) as validate, + ): + result = await TaskQueueService.enqueue_tasks( + ["Check PC"], + device_serial="offline-phone", + runtime_overrides={"execution_target": "computer"}, + ) + item = result["tasks"][0] + assert item["device_serial"] == "computer:local" + assert item["plugin_resources"] == ["desktop:local"] + assert item["runtime_overrides"]["plugins"][0]["version"] == "1.0.0" + reserve.assert_called_once_with(item["session_id"], pinned, "computer") + select.assert_not_awaited() + validate.assert_not_awaited() + + @pytest.mark.parametrize( ("current_status", "returncode", "stopped", "expected"), [ @@ -1008,6 +1041,63 @@ async def test_enqueue_tasks_deduplicates_by_session_id(): assert res2["tasks"][0]["session_id"] == "sid-dedup-1" +@pytest.mark.asyncio +@pytest.mark.parametrize("targets", [("computer", "android"), ("android", "computer")]) +async def test_pc_and_android_dispatch_in_submission_order(monkeypatch, targets): + monkeypatch.setenv("ARTEMIS_CONCURRENCY_MODE", "per_device") + monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "8") + monkeypatch.setattr(state, "active_runs", {}) + monkeypatch.setattr(TaskQueueService, "_run_tasks", set()) + monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None) + execute = AsyncMock() + monkeypatch.setattr(TaskQueueService, "_execute_task_item", execute) + state.queue_items = [ + { + "session_id": target, + "status": "pending", + "device_serial": target, + "execution_target": "computer" if target == "computer" else "android", + } + for target in targets + ] + first = state.queue_items[0] + TaskQueueService._dispatch_pending_tasks() + await asyncio.gather(*TaskQueueService._run_tasks) + assert [item["status"] for item in state.queue_items] == ["running", "pending"] + # A launched worker occupies the slot before process registration. + TaskQueueService._dispatch_pending_tasks() + assert execute.await_count == 1 + state.queue_items.remove(first) + TaskQueueService._dispatch_pending_tasks() + await asyncio.gather(*TaskQueueService._run_tasks) + assert [call.args[0]["session_id"] for call in execute.await_args_list] == list(targets) + + +@pytest.mark.asyncio +async def test_serial_dispatch_does_not_bypass_blocked_queue_head(monkeypatch): + monkeypatch.setattr(state, "active_runs", {}) + monkeypatch.setattr(TaskQueueService, "_run_tasks", set()) + monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None) + monkeypatch.setattr( + "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners", + lambda: {"external": MagicMock(resource_ids=("shared-desktop",))}, + ) + execute = AsyncMock() + monkeypatch.setattr(TaskQueueService, "_execute_task_item", execute) + state.queue_items = [ + { + "session_id": "first", + "status": "pending", + "device_serial": "android", + "plugin_resources": ["shared-desktop"], + }, + {"session_id": "second", "status": "pending", "device_serial": "computer"}, + ] + TaskQueueService._dispatch_pending_tasks() + assert [item["status"] for item in state.queue_items] == ["pending", "pending"] + execute.assert_not_called() + + @pytest.mark.asyncio async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run(): """Stopping run A must not flip run B's terminal status or its payload. diff --git a/tests/unit/agents/test_checker.py b/tests/unit/agents/test_checker.py index 9cb299e..dcacd99 100644 --- a/tests/unit/agents/test_checker.py +++ b/tests/unit/agents/test_checker.py @@ -144,6 +144,78 @@ def test_normalize_report_downgrades_vague_failures_and_fills_missing(): # --- Assembly: prompt segments x entry x content x config (§8 item 18) --------------- +@pytest.mark.parametrize("kind,when", [("verify", "on_complete"), ("assert", "at_end")]) +@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive"]) +@pytest.mark.parametrize("bullet", ["", "- "]) +def test_rendered_check_label_matches_original_without_changing_verdict(kind, when, status, bullet): + item = _ci(kind=kind, when=when, text="截图路径与回执一致") + verdict = CheckVerdict( + kind=kind, + item_text=f"{bullet}[{kind}] ({when}) {item.text}", + status=status, + evidence="截图与笔记回执", + suggestion="保留原建议", + ) + normalized = _normalize_report(CheckReport(verdicts=[verdict]), [item]) + assert [v.model_dump() for v in normalized.verdicts] == [ + {**verdict.model_dump(), "item_text": item.text} + ] + assert verdict.item_text != item.text # The original response is not rewritten. + + +@pytest.mark.parametrize( + "kind,text", + [ + ("assert", "[verify] (on_complete) 截图有效"), + ("verify", "[assert] (on_complete) 截图有效"), + ("verify", "[verify] (at_end) 截图有效"), + ("verify", "[verify] (on_complete) 截图无效"), + ("verify", "[verify] (on_complete) 截图有效。"), + ], +) +def test_unmatched_label_does_not_cover_declared_check(kind, text): + item = _ci(text="截图有效") + verdict = CheckVerdict(kind=kind, item_text=text, status="passed", evidence="e") + normalized = _normalize_report(CheckReport(verdicts=[verdict]), [item]) + assert normalized.verdicts[0] == verdict + assert normalized.verdicts[1].item_text == item.text + assert normalized.verdicts[1].status == "inconclusive" + + +def test_literal_criterion_takes_precedence_over_another_items_display_label(): + plain = _ci(text="截图有效") + literal = _ci(text="[verify] (on_complete) 截图有效") + verdict = CheckVerdict(kind="verify", item_text=literal.text, status="passed", evidence="e") + normalized = _normalize_report(CheckReport(verdicts=[verdict]), [plain, literal]) + assert [(v.item_text, v.status) for v in normalized.verdicts] == [ + (literal.text, "passed"), + (plain.text, "inconclusive"), + ] + + +def test_conflicting_plain_and_label_verdicts_keep_the_failure(): + item = _ci(text="截图有效") + normalized = _normalize_report( + CheckReport( + verdicts=[ + CheckVerdict(kind="verify", item_text=item.text, status="passed", evidence="e1"), + CheckVerdict( + kind="verify", + item_text="[verify] (on_complete) 截图有效", + status="failed", + evidence="e2", + ), + ] + ), + [item], + ) + assert [(v.item_text, v.status) for v in normalized.verdicts] == [ + (item.text, "passed"), + (item.text, "failed"), + ] + assert not verdicts_allow_release(normalized) + + def test_prompt_segments_verify_only_has_no_assert_section(): prompts = { "base_rules": "BASE", diff --git a/tests/unit/agents/test_checker_structured_report.py b/tests/unit/agents/test_checker_structured_report.py index 201e6d9..21b05dd 100644 --- a/tests/unit/agents/test_checker_structured_report.py +++ b/tests/unit/agents/test_checker_structured_report.py @@ -139,6 +139,42 @@ def test_empty_evidence_list_does_not_turn_an_unsupported_failure_into_evidence( assert _normalize_report(report, []).verdicts[0].status == "inconclusive" +@pytest.mark.asyncio +@pytest.mark.parametrize("structured_response", [False, True]) +async def test_display_labels_match_in_text_and_structured_verdict_paths(structured_response): + from artemis.agents.checker.checker import _normalize_report + from artemis.utils.plan_grammar import CheckItem + + items = [ + CheckItem(kind="verify", when="on_complete", text="截图保存成功", parent_key="p"), + CheckItem(kind="verify", when="on_complete", text="路径与回执一致", parent_key="p"), + CheckItem(kind="assert", when="at_end", text="没有交互动作", parent_key=None), + ] + payload = { + "verdicts": [ + { + "kind": i.kind, + "item_text": f"[{i.kind}] ({i.when}) {i.text}", + "status": "passed", + "evidence": "完整工具回执", + } + for i in items + ], + "test_result": _result(), + } + llm, structured = _llm(CheckReport.model_validate(payload)) + answer = "已检查,待结构化输出。" if structured_response else json.dumps(payload) + report = await _structured_report(llm, [AIMessage(content=answer)], items) + normalized = _normalize_report(report, items) + assert [v.item_text for v in normalized.verdicts] == [i.text for i in items] + assert [v.status for v in normalized.verdicts] == ["passed"] * 3 + assert normalized.test_result.status == "passed" + if structured_response: + structured.ainvoke.assert_awaited_once() + else: + structured.ainvoke.assert_not_awaited() + + def test_nested_json_string_result_is_decoded_and_still_validated(): result = _result() assert ( diff --git a/tests/unit/agents/test_execution_incident_prompt.py b/tests/unit/agents/test_execution_incident_prompt.py index b19b115..44d73c7 100644 --- a/tests/unit/agents/test_execution_incident_prompt.py +++ b/tests/unit/agents/test_execution_incident_prompt.py @@ -145,6 +145,19 @@ def test_render_shifted_and_occupied_evidence(): assert "now covered by 'Sign Up' button" in text +def test_unavailable_pixel_verification_does_not_claim_target_disappeared(): + incident = _disappeared( + category="pixel_verification_unavailable", + reason="Failed to acquire live screenshot: device offline", + ) + text = render_execution_incident(incident, STEPS) + assert "device offline" in text + assert "was NOT executed" in text + assert "Restore a working observation" in text + assert "no longer on screen" not in text + assert "Your last dispatched action" not in text + + def test_render_burst_abort_and_exec_error(): burst = _disappeared(kind=KIND_EXEC_ERROR, category="general", burst_size=3, action_index=1) burst["reason"] = "Error: tap rejected" diff --git a/tests/unit/agents/test_operator_recovery.py b/tests/unit/agents/test_operator_recovery.py index 234c66f..f42fb5a 100644 --- a/tests/unit/agents/test_operator_recovery.py +++ b/tests/unit/agents/test_operator_recovery.py @@ -135,7 +135,12 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action( result = await node._invoke_llm_loop( Mock(), messages, [SimpleNamespace(name="save_note")], [], state ) - if finishing: + if scenario == "pro": + assert invoke.await_count == 1 + assert result[0] is None + assert state.execution_report is None + assert result[3] is False + elif finishing: assert invoke.await_count == 2 assert result[0] is None assert final.content in result[1] @@ -154,6 +159,112 @@ async def test_completed_exploration_plan_does_not_force_an_extra_action( assert any(isinstance(m, ToolMessage) and m.tool_call_id == "plan" for m in messages) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("plan", "released", "finishes"), + [ + ("- [x] Inspect page", False, True), + ("- [ ] Inspect page", False, False), + ("- [!] Inspect page", False, False), + ("- [x] Inspect page\n - [ ] Check input", False, False), + ("- [x] Inspect page\n - [/] Check input", False, False), + ("- [x] [Loop:continuous] Inspect page", False, False), + ("- [x] [Loop:continuous] Inspect page", True, True), + ("", False, False), + ], +) +async def test_pro_completion_does_not_request_a_synthetic_action( + operator, monkeypatch, plan, released, finishes +): + node, state = operator + node._read_task_plan_text = lambda: plan + state.user_stop_requested = released + result, messages, invoke = await run_loop(operator, monkeypatch, [click()]) + assert invoke.await_count == (0 if finishes else 1) + assert bool(result[0]) is not finishes + assert state.execution_report is None + assert result[3] is False + + +@pytest.mark.asyncio +async def test_pro_completion_on_last_tool_iteration_still_reaches_final_review( + operator, monkeypatch, tmp_path +): + from artemis.context import ExecutionSetup + from artemis.graph.graph import convergence_gate + from artemis.utils.notes import get_note_file_path + + node, state = operator + node.ctx.data_engine = Mock(base_dir=str(tmp_path)) + node.ctx.execution_setup = ExecutionSetup(disable_checker=False) + path = get_note_file_path(tmp_path, "task_plan") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("- [/] Inspect page\n- assert@end: Input is empty", encoding="utf-8") + + async def save_note(**kwargs): + path.write_text("- [x] Inspect page\n- assert@end: Input is empty", encoding="utf-8") + return ToolMessage(content="Saved plan", tool_call_id="plan") + + monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 1) + monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", save_note) + invoke = AsyncMock( + return_value=AIMessage( + content="Input is empty; recording completion.", + tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}], + ) + ) + monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke) + result = await node._invoke_llm_loop(Mock(), [], [SimpleNamespace(name="save_note")], [], state) + assert result[0] is None + assert result[3] is False + assert invoke.await_count == 1 + assert convergence_gate(state, node.ctx) == "exit_settlement" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("feedback_field", ["injected_instruction", "operator_feedback"]) +async def test_completed_pro_plan_still_processes_new_feedback( + operator, monkeypatch, feedback_field +): + node, state = operator + node.ctx.data_engine = None + node._read_task_plan_text = lambda: "- [x] Inspect page" + setattr(state, feedback_field, "Review the saved evidence before finishing.") + monkeypatch.setattr( + "artemis.agents.operator.operator.invoke_tool_with_injection", + AsyncMock(return_value=ToolMessage(content="Saved evidence", tool_call_id="read")), + ) + invoke = AsyncMock( + side_effect=[ + AIMessage( + content="Reading evidence.", + tool_calls=[ + {"name": "read_note", "args": {"key": "evidence"}, "id": "read"}, + ], + ), + AIMessage(content="Reviewed the evidence; input is empty."), + ] + ) + monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke) + messages = [] + result = await node._invoke_llm_loop( + Mock(), messages, [SimpleNamespace(name="read_note")], [], state + ) + assert invoke.await_count == 2 + assert result[0] is None + assert result[3] is False + assert messages[-1].content == "Reviewed the evidence; input is empty." + + +def test_pro_completion_does_not_bypass_pending_guidance(operator, monkeypatch, tmp_path): + node, state = operator + node._read_task_plan_text = lambda: "- [x] Inspect page" + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + node.ctx.task_control = task_control.TaskControl("pro-guidance", Mock()) + task_control.add_instruction("pro-guidance", "Inspect another input first.") + assert not node._pro_ready_for_settlement(state) + + @pytest.mark.asyncio async def test_final_report_is_discarded_when_guidance_arrives(operator, monkeypatch): node, state = operator diff --git a/tests/unit/agents/test_precondition_pixel_target.py b/tests/unit/agents/test_precondition_pixel_target.py index 35bc1ac..820f468 100644 --- a/tests/unit/agents/test_precondition_pixel_target.py +++ b/tests/unit/agents/test_precondition_pixel_target.py @@ -21,6 +21,7 @@ from unittest.mock import AsyncMock import base64 import pytest from PIL import Image +from PIL import ImageDraw from artemis.agents.validator import precondition_pixel as pp from langchain_core.messages import HumanMessage, SystemMessage @@ -28,6 +29,87 @@ from langchain_core.messages import HumanMessage, SystemMessage _PNG = b"fake-image-bytes" +def _encode_image(image): + buffer = BytesIO() + image.save(buffer, format="PNG") + return base64.b64encode(buffer.getvalue()).decode() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("point", [[410, 2310], [1195, 251]]) +async def test_pixel_judge_sees_device_target_in_each_resized_image(monkeypatch, point): + monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt")) + completion = AsyncMock( + return_value={ + "is_present": True, + "confidence": 0.99, + "target_kind": "control", + "target_matches": True, + } + ) + monkeypatch.setattr(pp, "acomplete_structured", completion) + ctx = MagicMock() + ctx.device.device_width, ctx.device.device_height = 1280, 2856 + reference = _encode_image(Image.new("RGB", (918, 2048), "white")) + session = AsyncMock() + session.screenshot_b64.return_value = _encode_image(Image.new("RGB", (640, 1428), "white")) + action = {"action": "tap", "coordinates": point.copy(), "target_text": "Input"} + + verdict = await pp.validate_action_precondition_pixel( + ctx, + session, + action, + reference, + get_llm_fn=lambda: None, + ) + + assert verdict[0] is True + assert action["coordinates"] == point # Dispatch still uses native device pixels. + content = completion.await_args.args[1][1].content + images = [item["image_url"]["url"] for item in content if item["type"] == "image_url"] + assert len(images) == 2 + for url in images: + with Image.open(BytesIO(base64.b64decode(url.split(",", 1)[1]))) as marked: + x = round(point[0] * marked.width / 1280) + y = round(point[1] * marked.height / 2856) + red, green, blue = marked.getpixel((x, y)) + assert red > 180 and green < 60 and blue < 60 + wrong = (min(point[0], marked.width - 1), min(point[1], marked.height - 1)) + assert min(marked.getpixel(wrong)) > 200 + + +@pytest.mark.asyncio +async def test_negative_cache_tracks_scaled_target_neighborhood(monkeypatch): + monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt")) + attempt = AsyncMock( + return_value=(False, pp.ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, "missing") + ) + monkeypatch.setattr(pp, "_run_attempt", attempt) + ctx = MagicMock() + ctx.device.device_width, ctx.device.device_height = 1280, 2856 + image = Image.new("RGB", (918, 2048), "white") + frames = [_encode_image(image)] + ImageDraw.Draw(image).rectangle((260, 1620, 330, 1700), fill="blue") + frames.append(_encode_image(image)) + ImageDraw.Draw(image).rectangle((0, 0, 20, 20), fill="black") + frames.append(_encode_image(image)) + for frame in frames: + await pp.validate_action_precondition_pixel( + ctx, + AsyncMock(), + {"action": "tap", "coordinates": [410, 2310], "target_description": "Input"}, + frame, + get_llm_fn=lambda: None, + ) + assert attempt.await_count == 2 + + +def test_changed_orientation_does_not_silently_move_marker(): + image = base64.b64decode(_encode_image(Image.new("RGB", (2048, 918)))) + with pytest.raises(ValueError, match="geometry changed"): + pp._annotate_target(image, [410, 2310], (1280, 2856)) + + @pytest.mark.asyncio @pytest.mark.parametrize( "response", @@ -80,6 +162,56 @@ async def test_renaming_rejected_point_cannot_dispatch_again(monkeypatch): assert attempt.await_count == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "screenshot_error", [RuntimeError("device offline"), TimeoutError("capture timeout")] +) +async def test_screenshot_failure_is_unavailable_and_preserves_cause(monkeypatch, screenshot_error): + monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt")) + monkeypatch.setattr(pp.visualization, "crop_and_annotate_target", lambda *a, **k: _PNG) + monkeypatch.setattr(pp, "_RETRY_DELAY_SECONDS", 0) + complete = AsyncMock() + monkeypatch.setattr(pp, "acomplete_structured", complete) + session = AsyncMock() + session.screenshot_b64.side_effect = screenshot_error + ctx = MagicMock() + ctx.rejected_pixel_targets = {} + action = {"action": "tap", "coordinates": [200, 300], "target_text": "Projects"} + for _ in range(2): + passed, category, reason = await pp.validate_action_precondition_pixel( + ctx, session, action, base64.b64encode(_PNG).decode(), get_llm_fn=lambda: None + ) + assert passed is False + assert category == pp.ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE + assert str(screenshot_error) in reason + assert "action was not executed" in reason + assert session.screenshot_b64.await_count == 2 * pp._MAX_ATTEMPTS + assert not ctx.rejected_pixel_targets + complete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_model_failure_is_unavailable_without_losing_parse_error(monkeypatch): + monkeypatch.setattr(pp, "_init_llm_and_prompt", lambda *a: (None, "prompt")) + monkeypatch.setattr(pp.visualization, "crop_and_annotate_target", lambda *a, **k: _PNG) + monkeypatch.setattr(pp, "_RETRY_DELAY_SECONDS", 0) + monkeypatch.setattr( + pp, "acomplete_structured", AsyncMock(side_effect=ValueError("invalid response")) + ) + session = AsyncMock() + session.screenshot_b64.return_value = base64.b64encode(_PNG).decode() + passed, category, reason = await pp.validate_action_precondition_pixel( + MagicMock(), + session, + {"action": "tap", "coordinates": [200, 300], "target_text": "Projects"}, + base64.b64encode(_PNG).decode(), + get_llm_fn=lambda: None, + ) + assert passed is False + assert category == pp.ValidationErrorCategory.PIXEL_VERIFICATION_UNAVAILABLE + assert "invalid response" in reason + + def _texts(messages: list) -> list[str]: human = messages[1] return [part["text"] for part in human.content if part["type"] == "text"] diff --git a/tests/unit/agents/test_validator.py b/tests/unit/agents/test_validator.py index dae0011..bc66503 100644 --- a/tests/unit/agents/test_validator.py +++ b/tests/unit/agents/test_validator.py @@ -638,7 +638,7 @@ async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_s # Mock crop and draw helpers to return dummy bytes with ( patch( - "artemis.utils.visualization.crop_and_annotate_target", + "artemis.agents.validator.precondition_pixel._annotate_target", return_value=b"dummy_bytes", ), patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm), @@ -680,7 +680,7 @@ async def test_validator_pixel_validation_failure(mock_mcp, mock_context, temp_s with ( patch( - "artemis.utils.visualization.crop_and_annotate_target", + "artemis.agents.validator.precondition_pixel._annotate_target", return_value=b"dummy_bytes", ), patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm), diff --git a/tests/unit/clients/test_screen_client_factory.py b/tests/unit/clients/test_screen_client_factory.py index 836af3b..aa14258 100644 --- a/tests/unit/clients/test_screen_client_factory.py +++ b/tests/unit/clients/test_screen_client_factory.py @@ -86,6 +86,28 @@ def test_healthy_helper_never_constructs_uiautomator(composite): factory.assert_not_called() +def test_clear_reply_loss_never_replays_on_fallback_backend(composite): + client, helper, u2, factory, _ = composite + client.connect() + helper.clear_text.side_effect = HelperUnavailable("reply lost after clear") + with pytest.raises(HelperUnavailable): + client.clear_text() + helper.clear_text.assert_called_once_with() + factory.assert_not_called() + u2.clear_text.assert_not_called() + + +def test_clear_uses_the_backend_that_observed_the_field(composite): + client, helper, u2, _, clock = composite + helper.connect.side_effect = HelperUnavailable("offline") + client.connect() + clock["now"] = 100.0 # Helper recovery is due, but must wait for observation. + u2.clear_text.return_value = True + assert client.clear_text() is True + helper.clear_text.assert_not_called() + u2.clear_text.assert_called_once_with() + + def test_connect_failure_degrades_to_uiautomator(composite): client, helper, u2, factory, _ = composite helper.connect.side_effect = HelperUnavailable("not installed") diff --git a/tests/unit/clients/test_ui_automator_startup.py b/tests/unit/clients/test_ui_automator_startup.py index 1f59659..4f9e806 100644 --- a/tests/unit/clients/test_ui_automator_startup.py +++ b/tests/unit/clients/test_ui_automator_startup.py @@ -12,6 +12,7 @@ from uiautomator2 import base, core from artemis.clients import ui_automator_client from artemis.clients.accessibility_client import HelperUnavailable from artemis.clients.screen_client_factory import FallbackScreenClient +from artemis.runtime import observation_budget @dataclass @@ -128,6 +129,53 @@ def test_each_failed_launch_is_closed_before_retry_and_final_failure(server): assert "3" in str(caught.value) +@pytest.mark.parametrize("helper_seconds", [0, 18, 59]) +def test_helper_and_uiautomator_share_observation_deadline(server, monkeypatch, helper_seconds): + monkeypatch.setattr(observation_budget, "monotonic", server.clock.time) + helper = MagicMock() + + def unavailable(): + server.clock.sleep(helper_seconds) + raise HelperUnavailable("helper snapshot timed out") + + helper.get_screen_data.side_effect = unavailable + client = FallbackScreenClient( + "isolated-startup-device", + helper=helper, + state_probe=lambda _serial: "device", + ) + budget = observation_budget.ObservationBudget(deadline=60) + token = observation_budget.current_observation.set(budget) + try: + with pytest.raises(TimeoutError, match="预算耗尽"): + client.get_screen_data() + finally: + observation_budget.current_observation.reset(token) + client.disconnect() + assert server.clock.now == 60 + assert len(server.connections) <= 2 + assert all(c.close_calls == 1 for c in server.connections) + assert client.active_backend is None + + +def test_disconnect_during_connect_discards_and_stops_late_device(server, monkeypatch): + client = ui_automator_client.UIAutomatorClient("isolated-startup-device") + candidate = MagicMock() + + def late_connection(_device_id): + client.disconnect(stop_server=True) + return candidate + + factory = MagicMock(side_effect=late_connection) + monkeypatch.setattr(ui_automator_client, "_ManagedUIAutomatorDevice", factory) + with pytest.raises(RuntimeError, match="连接已取消"): + client.connect() + factory.assert_called_once() + candidate.stop_uiautomator.assert_called_once() + assert client._device is None + assert client._awake_strategy is None + + def test_retry_can_connect_after_failed_launch_was_stopped(server): server.modes = ["not_ready", "ready"] client = ui_automator_client.UIAutomatorClient("isolated-startup-device") diff --git a/tests/unit/graph/test_graph.py b/tests/unit/graph/test_graph.py index e8ace06..eb3a203 100644 --- a/tests/unit/graph/test_graph.py +++ b/tests/unit/graph/test_graph.py @@ -103,6 +103,84 @@ async def test_wrap_update_note_tool_valid_status_change(tmp_path): assert ctx.planner_task is None +@pytest.mark.asyncio +async def test_short_plan_replacement_cannot_duplicate_untouched_tail(tmp_path): + original = "- [/] Enter text\n- [ ] Clear text\n - verify: Input is empty\n" + path = _setup_plan(tmp_path, original) + ctx = _make_ctx(tmp_path, disable_planner_validation=True) + with patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=_fake_update_invoke(path), + ): + result = await wrap_update_note_tool(ctx, _mock_tool("update_note")).ainvoke( + { + "key": "task_plan", + "target": "- [/] Enter text", + "replacement": "- [x] Enter text\n- [/] Clear text", + "tool_call_id": "duplicate-tail", + } + ) + assert isinstance(result, ToolMessage) + assert result.status == "error" + assert "Clear text" in result.content + assert "target" in result.content + assert path.read_text(encoding="utf-8") == original + assert ctx.planner_task is None + assert ctx.pending_checkpoints == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("keep_existing_duplicate", [False, True]) +async def test_duplicate_plan_can_be_repaired_with_a_complete_target( + tmp_path, keep_existing_duplicate +): + original = "- [x] Enter text\n- [/] Clear text\n- [ ] Clear text\n" + path = _setup_plan(tmp_path, original) + ctx = _make_ctx(tmp_path, disable_planner_validation=True) + replacement = "- [x] Enter text\n- [/] Clear text\n" + if keep_existing_duplicate: + replacement += "- [x] Clear text\n" + with patch( + "artemis.graph.graph.invoke_tool_with_injection", + side_effect=_fake_update_invoke(path), + ): + result = await wrap_update_note_tool(ctx, _mock_tool("update_note")).ainvoke( + { + "key": "task_plan", + "target": original, + "replacement": replacement, + "tool_call_id": "repair-tail", + } + ) + assert result == "Success" + assert path.read_text(encoding="utf-8") == replacement + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_name", ["save_note", "append_note"]) +async def test_other_plan_writes_cannot_introduce_duplicate_milestones(tmp_path, tool_name): + original = "- [/] Enter text\n- [ ] Clear text\n" + path = _setup_plan(tmp_path, original) + ctx = _make_ctx(tmp_path, disable_planner_validation=True) + + async def fake_invoke(tool, args, tool_call_id, state, record_trace=None): + content = args["content"] + if tool_name == "append_note": + content = path.read_text(encoding="utf-8") + content + path.write_text(content, encoding="utf-8") + return "Success" + + content = "- [/] Clear text\n" + if tool_name == "save_note": + content = original + content + with patch("artemis.graph.graph.invoke_tool_with_injection", side_effect=fake_invoke): + result = await wrap_note_tool(ctx, _mock_tool(tool_name)).ainvoke( + {"key": "task_plan", "content": content, "tool_call_id": "duplicate"} + ) + assert result.status == "error" + assert path.read_text(encoding="utf-8") == original + + @pytest.mark.asyncio async def test_wrap_update_note_tool_intentional_rename_triggers_validation(tmp_path): task_plan_path = _setup_plan(tmp_path) diff --git a/tests/unit/mcp/test_actuators.py b/tests/unit/mcp/test_actuators.py index 4a9d63a..1e65bae 100644 --- a/tests/unit/mcp/test_actuators.py +++ b/tests/unit/mcp/test_actuators.py @@ -113,8 +113,9 @@ async def test_input_text_clear_erases_then_types_without_reclearing(actuator, m res = await actuator.input_text("hello", (500, 300), clear_exist=True) assert res.ok actions = [h["action"] for h in actuator.action_history] - # The controller's full clear runs as a shell keyevent batch on this driver. - assert actions == ["tap", "execute_shell", "input_text"] + assert actions == ["tap", "input_text", "input_text"] + assert actuator.action_history[-2]["text"] == "" + assert actuator.action_history[-2]["clear_existing"] is True assert actuator.action_history[-1]["clear_existing"] is False diff --git a/tests/unit/mcp/test_input_verification.py b/tests/unit/mcp/test_input_verification.py new file mode 100644 index 0000000..d82a65d --- /dev/null +++ b/tests/unit/mcp/test_input_verification.py @@ -0,0 +1,96 @@ +"""Pro and Flash must judge clear versus fill against fresh input evidence.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from artemis.agents.validator.action_execution import exec_action +from artemis.mcp.action_executor import McpActionExecutor +from artemis.mcp.action_types import ActionResult, ObserveResult + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["pro", "flash"]) +@pytest.mark.parametrize( + ("text", "clear", "observed", "success"), + [ + ("", True, "", True), + ("", True, "AR", False), + ("", True, None, False), + ("new", True, "new", True), + ("new", True, "", False), + ("new", False, "oldnew", True), + ("new", False, None, False), + ("", False, "", True), + ("", False, "old", True), + ], +) +async def test_input_verification_matches_intent(mode, text, clear, observed, success): + ctx = SimpleNamespace( + data_engine=None, device=SimpleNamespace(device_width=100, device_height=100) + ) + element = { + "class": "android.widget.EditText", + "text": observed, + "focused": True, + "bounds": "[0,0][100,100]", + "center": [50, 50], + } + session = Mock() + session.call = AsyncMock(return_value=ActionResult.success("input_text", "Dispatched")) + session.ui_hierarchy = AsyncMock(return_value=[element]) + if mode == "pro": + action = { + "action": "focus_and_input_text", + "coordinates": [50, 50], + "text": text, + "clear_before_input": clear, + } + ok, _ = await exec_action(ctx, session, action) + else: + executor = McpActionExecutor(ctx, actuator=Mock()) + executor._session_or_start = AsyncMock(return_value=session) + executor._observe_after_action = AsyncMock( + return_value=(ObserveResult(ok=True, elements=[element]), True) + ) + result = await executor._execute_device_action( + "input_text", + "input_text", + {"text": text, "clear_exist": clear}, + "input", + SimpleNamespace(latest_screenshot=None, indexed_elements=[]), + ) + ok = result.status == "success" + assert ok is success + session.call.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("observation_ok", [True, False]) +async def test_flash_cannot_confirm_clear_without_fresh_hierarchy(observation_ok): + ctx = SimpleNamespace(data_engine=None, device=None) + session = Mock() + session.call = AsyncMock(return_value=ActionResult.success("input_text", "Dispatched")) + executor = McpActionExecutor(ctx, actuator=Mock()) + executor._session_or_start = AsyncMock(return_value=session) + executor._observe_after_action = AsyncMock( + return_value=( + ObserveResult( + ok=observation_ok, + hierarchy_ok=False, + elements=[{"focused": True, "input_value_state": "empty", "center": [1, 1]}], + ), + True, + ) + ) + result = await executor._execute_device_action( + "input_text", + "input_text", + {"text": "", "clear_exist": True}, + "clear", + SimpleNamespace(latest_screenshot=None, indexed_elements=[]), + ) + assert result.status == "error" + assert result.metadata["input_verification"] == "unknown" + session.call.assert_awaited_once() diff --git a/tests/unit/mcp/test_mcp_tools.py b/tests/unit/mcp/test_mcp_tools.py index 7359626..5a7a5c6 100644 --- a/tests/unit/mcp/test_mcp_tools.py +++ b/tests/unit/mcp/test_mcp_tools.py @@ -180,6 +180,41 @@ def test_mobile_run_task_with_device_serial(temp_trace_env): assert status["device_serial"] == "pixel-11-pro-001" +@pytest.mark.parametrize("model", ["flash", "pro"]) +def test_pc_run_task_uses_daemon_without_android_validation(temp_trace_env, monkeypatch, model): + monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) + with ( + patch( + "mcp_server.tools.task_runner.ensure_daemon_running", + return_value=(True, "http://127.0.0.1:8000"), + ), + patch( + "mcp_server.tools.task_runner._validate_device_serial", + side_effect=AssertionError("ADB must not be used"), + ) as validate, + patch( + "mcp_server.tools.task_runner.submit_task_to_daemon", + side_effect=lambda **kwargs: { + "status": "started", + "tasks": [{"session_id": kwargs["session_id"]}], + }, + ) as submit, + patch("mcp_server.tools.task_runner.subprocess.Popen") as spawn, + ): + result = mobile_run_task( + "Check desktop", + model=model, + device_serial="offline-phone", + runtime_overrides={"execution_target": "computer"}, + ) + assert result["status"] == "pending" + assert submit.call_args.kwargs["runtime_overrides"]["execution_target"] == "computer" + assert submit.call_args.kwargs["device_serial"] == "computer:local" + assert trace_store.read_status(result["trace_id"])["device_serial"] == "computer:local" + validate.assert_not_called() + spawn.assert_not_called() + + def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch): monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) with ( @@ -201,12 +236,46 @@ def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch): popen.assert_not_called() assert result["trace_id"] == "daemon-sid-1" - assert result["status"] == "running" + assert result["status"] == "pending" assert "enqueued via unified Artemis Daemon" in result["message"] assert "stdout_log" in result assert "stderr_log" in result +@pytest.mark.parametrize("lost_reply", [False, True]) +@pytest.mark.parametrize("worker_status", ["pending", "running", "completed", "failed"]) +def test_daemon_receipt_preserves_queue_and_worker_status( + temp_trace_env, monkeypatch, lost_reply, worker_status +): + monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) + queued = [] + + def submit(**kwargs): + sid = kwargs["session_id"] + assert trace_store.read_status(sid)["status"] == "pending" + queued.append({"session_id": sid, "status": "pending"}) + if worker_status != "pending": + trace_store.update_trace_status(sid, worker_status) + return None if lost_reply else {"status": "started", "tasks": queued} + + with ( + patch( + "mcp_server.tools.task_runner.ensure_daemon_running", + return_value=(True, "http://127.0.0.1:8000"), + ), + patch("mcp_server.tools.task_runner.submit_task_to_daemon", side_effect=submit), + patch( + "mcp_server.tools.task_runner.DeviceExecutionLock.get_queued_tasks", + side_effect=lambda: queued, + ), + patch("mcp_server.tools.task_runner.subprocess.Popen") as spawn, + ): + result = mobile_run_task("Queue state regression", model="Pro") + assert result["status"] == worker_status + assert trace_store.read_status(result["trace_id"])["status"] == worker_status + spawn.assert_not_called() + + @pytest.mark.parametrize( ("kwargs", "match"), [ diff --git a/tests/unit/runtime/test_device_lock.py b/tests/unit/runtime/test_device_lock.py index 008f425..a28ec76 100644 --- a/tests/unit/runtime/test_device_lock.py +++ b/tests/unit/runtime/test_device_lock.py @@ -80,18 +80,14 @@ def test_same_serial_on_different_adb_endpoints_has_independent_locks(): ) first.acquire() - second.acquire() try: assert first.path != second.path - owners = DeviceExecutionLock.get_active_owners() - assert len(owners) == 2 - assert {owner.lock_scope for owner in owners.values()} == { - "tcp:127.0.0.1:5037", - "tcp:127.0.0.1:5038", - } + with pytest.raises(DeviceBusyError, match="concurrency limit"): + second.acquire(blocking=False) finally: - second.release() first.release() + second.acquire(blocking=False) + second.release() def test_device_lock_release_does_not_remove_replacement_owner(): @@ -185,28 +181,19 @@ def test_submission_reservations_preserve_order_before_workers_start(): thread.join(timeout=2.0) -def test_pending_reservation_at_queue_head_does_not_block_other_devices(): - """An unclaimed pending submission must not gate every device's queue head. - - Regression: a reservation whose device is still "pending" used to be - counted into every device's FIFO queue, so one unclaimed head-of-queue - ticket blocked all devices at once. It must now park on at most one idle - candidate device (the first in sorted order) and leave the others - schedulable. - """ +def test_pending_reservation_at_queue_head_preserves_global_submission_order(): + """Unresolved earlier submissions retain their place across target devices.""" pending_ticket = DeviceExecutionLock.reserve("unclaimed submission") - # device-alpha is a known idle device (a queued submission targets it), so - # the pending ticket parks there; device-beta must stay schedulable. alpha_ticket = DeviceExecutionLock.reserve("task for device alpha", device_id="device-alpha") try: beta = DeviceExecutionLock("device-beta", "task for device beta") - # Must acquire immediately even though the older pending ticket is at - # the head of the global queue directory. - beta.acquire(blocking=False) - beta.release() + with pytest.raises(DeviceBusyError, match="earlier queued task"): + beta.acquire(blocking=False) finally: DeviceExecutionLock.cancel_reservation(pending_ticket) DeviceExecutionLock.cancel_reservation(alpha_ticket) + beta.acquire(blocking=False) + beta.release() def test_pending_reservation_is_served_with_original_fifo_position_after_claim(): @@ -326,27 +313,58 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch): owner_lock.release() -def test_multi_device_locks_can_run_concurrently(): - lock_a = DeviceExecutionLock("emulator-5554", "task on device A") - lock_b = DeviceExecutionLock("pixel-9-test", "task on device B") +@pytest.mark.parametrize("targets", [("emulator-5554", "computer"), ("computer", "pixel-9-test")]) +@pytest.mark.parametrize("legacy_mode", ["per_device", "parallel", "global"]) +def test_all_targets_run_serially_even_with_legacy_parallel_options( + monkeypatch, targets, legacy_mode +): + monkeypatch.setenv("ARTEMIS_CONCURRENCY_MODE", legacy_mode) + monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "8") + lock_a = DeviceExecutionLock( + targets[0], "task A", concurrency_mode=legacy_mode, max_concurrency=0 + ) + lock_b = DeviceExecutionLock( + targets[1], "task B", concurrency_mode=legacy_mode, max_concurrency=8 + ) lock_a.acquire() try: - # Device B should be acquired without blocking since it's a different device - lock_b.acquire(blocking=False) - try: - active_owners = DeviceExecutionLock.get_active_owners() - assert "emulator-5554" in active_owners - assert "pixel-9-test" in active_owners - - # A second task on device A should be blocked - lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A") - with pytest.raises(DeviceBusyError, match="task on device A"): - lock_a_second.acquire(blocking=False) - finally: - lock_b.release() + with pytest.raises(DeviceBusyError, match="task A"): + lock_b.acquire(blocking=False) finally: lock_a.release() + lock_b.acquire(blocking=False) + lock_b.release() + + +def test_shared_execution_lease_prevents_racing_admission(): + """Both schedulers can see an idle snapshot; only one may create a lease.""" + first = DeviceExecutionLock("computer", "PC") + second = DeviceExecutionLock("android", "Android") + assert first._try_acquire_owner_lock() + try: + assert not second._try_acquire_owner_lock() + assert not second.path.exists() + finally: + first.release() + assert second._try_acquire_owner_lock() + second.release() + + +def test_same_session_nested_lease_does_not_wait_behind_other_tasks(): + parent = DeviceExecutionLock("android", "parent", session_id="same-session") + nested = DeviceExecutionLock("computer", "nested", session_id="same-session") + parent.acquire() + earlier = DeviceExecutionLock.reserve("another task", device_id="computer") + try: + nested.acquire(blocking=False) + nested.release() + # Borrowing the shared resource must not delete the parent's lease. + challenger = DeviceExecutionLock("another-device", "challenger") + assert not challenger._try_acquire_owner_lock() + finally: + DeviceExecutionLock.cancel_reservation(earlier) + parent.release() def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch): diff --git a/tests/unit/runtime/test_task_control.py b/tests/unit/runtime/test_task_control.py index f1f5b6b..5db197e 100644 --- a/tests/unit/runtime/test_task_control.py +++ b/tests/unit/runtime/test_task_control.py @@ -14,6 +14,41 @@ import pytest import artemis.runtime.task_control as task_control +def test_worker_admission_and_lease_acquisition_are_distinct_mcp_states(tmp_path, monkeypatch): + from artemis.runtime import trace_store + from mcp_server.tools import task_manager + + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + monkeypatch.setattr(task_manager, "_find_data_engine_db", lambda: None) + for sid in ("worker", "waiting"): + trace_store.init_trace(sid, "read-only test", "Pro") + trace_store.update_trace_status(sid, "pending") + task_control.write_control_state("worker", "queued", reset=True) + assert trace_store.read_status("worker")["status"] == "pending" + + task_control.TaskControl("worker", object())._state("running") + + for sid, expected in (("worker", "running"), ("waiting", "pending")): + stored = trace_store.read_status(sid) + assert stored["status"] == expected + assert task_manager._reconcile_task_state(sid, stored)[0] == expected + + +@pytest.mark.parametrize("status", ["completed", "failed", "cancelled", "interrupted"]) +def test_late_worker_start_preserves_terminal_trace(tmp_path, monkeypatch, status): + from artemis.runtime import trace_store + + monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) + monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) + trace_store.init_trace("finished", "read-only test", "Pro") + before = trace_store.update_trace_status("finished", status, result={"kept": True}) + + task_control.TaskControl("finished", object())._state("running") + + assert trace_store.read_status("finished") == before + + def test_transition_timing_keeps_repeated_states_and_separates_pause_and_resource_wait( tmp_path, monkeypatch ): diff --git a/tests/unit/runtime/test_trace_store.py b/tests/unit/runtime/test_trace_store.py index fb5c295..0a3cf15 100644 --- a/tests/unit/runtime/test_trace_store.py +++ b/tests/unit/runtime/test_trace_store.py @@ -89,6 +89,32 @@ def test_update_trace_status_normalizes_success_alias(temp_trace_env): assert persisted["status"] == "completed" +def test_conditional_status_update_checks_state_after_acquiring_lock(temp_trace_env, monkeypatch): + from contextlib import contextmanager + + trace_store.init_trace("late-worker", "Test task", "Pro") + trace_store.update_trace_status("late-worker", "pending") + lock = trace_store._status_lock + terminal = {} + + @contextmanager + def cancelled_before_lock(path): + with lock(path): + data = trace_store.read_status("late-worker") + data.update(status="cancelled", result="user stopped", end_time=123) + trace_store.write_status("late-worker", data) + terminal.update(data) + yield + + monkeypatch.setattr(trace_store, "_status_lock", cancelled_before_lock) + result = trace_store.update_trace_status( + "late-worker", "running", expected_status="pending", result="must not overwrite" + ) + + assert result == terminal + assert trace_store.read_status("late-worker") == terminal + + def test_read_nonexistent_status(temp_trace_env): non_existent = str(uuid.uuid4()) assert trace_store.read_status(non_existent) is None diff --git a/tests/unit/test_android_files.py b/tests/unit/test_android_files.py index 4d03b8f..47548a5 100644 --- a/tests/unit/test_android_files.py +++ b/tests/unit/test_android_files.py @@ -74,6 +74,32 @@ async def test_existing_artifact_is_preserved_unless_explicit_overwrite(ctx, png assert json.loads(result)["ok"] and path.read_bytes() == png +@pytest.mark.asyncio +async def test_screenshot_receipt_repairs_pro_note_reference_without_recapture( + ctx, png, monkeypatch +): + from artemis.tools.scratchpad import append_note + + run = AsyncMock(return_value=png) + monkeypatch.setattr(files.AndroidFiles, "_run", run) + receipt = json.loads( + await files.android_file.execute(ctx=ctx, action="screenshot", filename="round_empty.png") + ) + missing = ctx.data_engine.base_dir / "round_empty.png" + error = await append_note.execute(ctx=ctx, key="evidence", content=f"Screenshot: {missing}") + assert "Failed to append" in error + assert receipt["trace_path"] in error + assert not (ctx.data_engine.base_dir / "notes" / "evidence.md").exists() + corrected = await append_note.execute( + ctx=ctx, key="evidence", content=f"Screenshot: {receipt['trace_path']}" + ) + assert corrected == "Appended to note 'evidence'." + assert receipt["trace_path"] in (ctx.data_engine.base_dir / "notes" / "evidence.md").read_text( + encoding="utf-8" + ) + assert run.await_count == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize( "name", ["../escape.png", "C:\\out.png", "x:y.png", "CON.png", "x.png ", "a\n.png"] diff --git a/tests/unit/test_drivers.py b/tests/unit/test_drivers.py index 7012e63..1403ee6 100644 --- a/tests/unit/test_drivers.py +++ b/tests/unit/test_drivers.py @@ -47,6 +47,35 @@ def _valid_png(): _VALID_PNG = _valid_png() +@pytest.mark.asyncio +async def test_disconnect_prevents_late_helper_failure_from_starting_uiautomator(): + from artemis.clients.accessibility_client import HelperUnavailable + from artemis.clients.screen_client_factory import FallbackScreenClient + + started, released = threading.Event(), threading.Event() + helper, factory, adb = MagicMock(), MagicMock(), MagicMock() + + def capture(): + started.set() + released.wait(2) + raise HelperUnavailable("late snapshot failure") + + helper.get_screen_data.side_effect = capture + screen = FallbackScreenClient("test-device", helper=helper, uiautomator_factory=factory) + driver = AndroidAdbDriver("test-device", adb, ui_adb_client=screen) + pending = asyncio.create_task(driver.get_screen_data(skip_settling=True)) + try: + assert await asyncio.to_thread(started.wait, 1) + await driver.disconnect() + finally: + released.set() + with pytest.raises(RuntimeError, match="观测已取消"): + await asyncio.wait_for(pending, 2) + factory.assert_not_called() + adb.device.assert_not_called() + assert screen.active_backend is None + + @pytest.mark.asyncio async def test_android_driver_rejects_placeholder_without_returning_stale_screen(): client = MagicMock() @@ -77,7 +106,9 @@ async def test_screen_capture_timeout_does_not_start_overlapping_sdk_calls(monke assert client.get_screen_data.call_count == 1 finally: released.set() - await asyncio.wait_for(driver._screen_capture_task, 2) + with pytest.raises(TimeoutError, match="预算耗尽"): + await asyncio.wait_for(driver._screen_capture_task, 2) + client.get_hierarchy.assert_not_called() def test_screenshot_limit_keeps_small_and_invalid_images_unchanged(): diff --git a/tests/unit/test_input_clearing.py b/tests/unit/test_input_clearing.py new file mode 100644 index 0000000..46d1ebb --- /dev/null +++ b/tests/unit/test_input_clearing.py @@ -0,0 +1,99 @@ +"""Whole-field clearing must not depend on text length or keyboard shortcuts.""" + +from unittest.mock import Mock + +import pytest + +from artemis.controllers.unified_controller import UnifiedMobileController +from artemis.drivers.android.adb_driver import AndroidAdbDriver +from artemis.drivers.android.input_ime import AndroidInputIME + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "value", + ["ARTEMIS_PRO_R3_20261026", "长文本🙂\n" * 80, ""], + ids=["23-chars", "long-unicode-multiline", "empty"], +) +async def test_full_clear_replaces_the_whole_field(value): + field = {"text": value} + device, ui = Mock(), Mock() + + def shell(command, **kwargs): + # A field that ignores Ctrl+A: the previous implementation leaves AR. + if "67" in command: + field["text"] = field["text"][: -command.count("67")] + return "" + + def clear(): + field["text"] = "" + return True + + device.shell.side_effect = shell + ui.clear_text.side_effect = clear + driver = AndroidAdbDriver("test-device", Mock(), ui_adb_client=ui) + driver._device = device + controller = object.__new__(UnifiedMobileController) + controller._driver = driver + assert await controller.erase_text() + assert field["text"] == "" + ui.clear_text.assert_called_once_with() + device.shell.assert_not_called() + ui.set_clipboard.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("implementation", ["driver", "ime"]) +@pytest.mark.parametrize("failure", [False, RuntimeError("write reply lost")]) +async def test_failed_clear_never_types_or_retries(implementation, failure): + device, ui = Mock(), Mock() + if isinstance(failure, Exception): + ui.clear_text.side_effect = failure + else: + ui.clear_text.return_value = failure + if implementation == "driver": + driver = AndroidAdbDriver("test-device", Mock(), ui_adb_client=ui) + driver._device = device + result = await driver.input_text("replacement", clear_existing=True) + else: + result = await AndroidInputIME(device, ui).type_text("replacement", clear_existing=True) + assert result is False + ui.clear_text.assert_called_once_with() + device.shell.assert_not_called() + ui.set_clipboard.assert_not_called() + ui.send_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_missing_clear_backend_is_not_reported_as_success(): + driver = AndroidAdbDriver("test-device", Mock()) + driver._device = Mock() + assert not await driver.input_text("replacement", clear_existing=True) + driver._device.shell.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("implementation", ["driver", "ime"]) +async def test_empty_append_does_not_paste_or_send_keys(implementation): + device, ui = Mock(), Mock() + if implementation == "driver": + driver = AndroidAdbDriver("test-device", Mock(), ui_adb_client=ui) + driver._device = device + result = await driver.input_text("", clear_existing=False) + else: + result = await AndroidInputIME(device, ui).type_text("", clear_existing=False) + assert result is True + device.shell.assert_not_called() + ui.clear_text.assert_not_called() + ui.set_clipboard.assert_not_called() + + +@pytest.mark.asyncio +async def test_partial_delete_stops_at_first_failed_key(): + from unittest.mock import AsyncMock + + controller = object.__new__(UnifiedMobileController) + controller._driver = Mock() + controller._driver.press_key = AsyncMock(return_value=False) + assert not await controller.erase_text(nb_chars=3) + controller._driver.press_key.assert_awaited_once_with("delete") diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 6302a0b..3d1eeb7 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -280,7 +280,7 @@ server.run() @pytest.mark.parametrize("scope", ["", "test-adb-endpoint"]) -def test_resource_lock_conflicts_across_devices_without_holding_partial_leases( +def test_serial_resource_locks_across_devices_without_holding_partial_leases( tmp_path, monkeypatch, scope ): import artemis.runtime.device_lock as locks @@ -304,16 +304,20 @@ def test_resource_lock_conflicts_across_devices_without_holding_partial_leases( try: with pytest.raises(DeviceBusyError): second.acquire(blocking=False) - other.acquire(blocking=False) - assert len(DeviceExecutionLock.get_active_owners()) == 2 - assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == ("desktop:local",) + with pytest.raises(DeviceBusyError): + other.acquire(blocking=False) + assert len(DeviceExecutionLock.get_active_owners()) == 1 + resources = (DeviceExecutionLock.EXECUTION_RESOURCE, "desktop:local") + assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == resources assert DeviceExecutionLock.annotate_active_owner( session_id="first", device_id="phone-a", ingress="test" ) - assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == ("desktop:local",) + assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == resources finally: other.release() first.release() + other.acquire(blocking=False) + other.release() second.acquire(blocking=False) second.release() assert not DeviceExecutionLock.get_active_owners() @@ -562,6 +566,7 @@ server.run() other.release() assert await asyncio.wait_for(future, timeout=15) == (True, None) assert DeviceExecutionLock.get_active_owners()["computer-a"].resource_ids == ( + DeviceExecutionLock.EXECUTION_RESOURCE, "desktop:local", ) result = await runtime.call(next(iter(runtime.bindings)), {}) diff --git a/tests/unit/utils/test_notes.py b/tests/unit/utils/test_notes.py index 2c82c84..488c755 100644 --- a/tests/unit/utils/test_notes.py +++ b/tests/unit/utils/test_notes.py @@ -23,6 +23,60 @@ from artemis.utils.notes import ( import pytest +@pytest.mark.parametrize("operation", ["save", "append", "update"]) +@pytest.mark.parametrize("path_style", ["native", "posix"]) +def test_note_rejects_misplaced_artifact_and_accepts_exact_receipt(tmp_path, operation, path_style): + artifact = tmp_path / "artifacts" / "android" / "截图 one.png" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b"evidence") + save_note_content(tmp_path, "evidence", "original") + note = tmp_path / "notes" / "evidence.md" + before = note.read_bytes() + missing = tmp_path / artifact.name + wrong = str(missing) if path_style == "native" else missing.as_posix() + + def write(content): + if operation == "save": + save_note_content(tmp_path, "evidence", content) + elif operation == "append": + append_note_content(tmp_path, "evidence", content) + else: + update_note_content(tmp_path, "evidence", "original", content) + + with pytest.raises(ValueError, match="exact trace_path") as error: + write(f"Screenshot: `{wrong}`。") + assert str(artifact) in str(error.value) + assert note.read_bytes() == before + assert artifact.read_bytes() == b"evidence" + write(f"Screenshot: `{artifact}`") + assert str(artifact) in note.read_text(encoding="utf-8") + + +def test_note_does_not_guess_between_duplicate_artifact_names(tmp_path): + files = [ + tmp_path / "artifacts" / "android" / "screen.png", + tmp_path / "artifacts" / "plugins" / "cua-driver" / "screen.png", + ] + for path in files: + path.parent.mkdir(parents=True) + path.write_bytes(b"evidence") + with pytest.raises(ValueError) as error: + save_note_content(tmp_path, "evidence", str(tmp_path / "screen.png")) + assert all(str(path) in str(error.value) for path in files) + assert not (tmp_path / "notes" / "evidence.md").exists() + + +def test_note_allows_existing_root_files_and_unknown_future_paths(tmp_path): + artifact = tmp_path / "artifacts" / "android" / "screen.png" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b"artifact") + root_file = tmp_path / "screen.png" + root_file.write_bytes(b"different root file") + content = f"{root_file}\n{tmp_path / 'future.png'}\n{tmp_path / 'screen.png.backup'}" + save_note_content(tmp_path, "plan", content) + assert read_note_content(tmp_path, "plan") == content + + @pytest.mark.parametrize("content", ["# 报告\n结果\n", "line\r\nsecond", ""]) def test_verified_note_receipt_matches_actual_file_bytes(tmp_path, content): import hashlib