diff --git a/deepseek-harness-plugin/src/client/TaskStopButton.tsx b/deepseek-harness-plugin/src/client/TaskStopButton.tsx index 88c821d..407512b 100644 --- a/deepseek-harness-plugin/src/client/TaskStopButton.tsx +++ b/deepseek-harness-plugin/src/client/TaskStopButton.tsx @@ -25,6 +25,14 @@ const squareStyle: CSSProperties = { background: '#dc2626', } +const errorStyle: CSSProperties = { + maxWidth: 220, + color: 'var(--dsw-alias-label-error, #b91c1c)', + fontSize: 11, + lineHeight: 1.3, + overflowWrap: 'anywhere', +} + /** Stop the active OpenGUI phone or browser task from the composer's right tool row. */ export function TaskStopButton({ coremateSessionId }: { readonly coremateSessionId?: string }): JSX.Element | null { const { task } = useCoremateTaskStatus() @@ -46,15 +54,18 @@ export function TaskStopButton({ coremateSessionId }: { readonly coremateSession if (!task.active || (task.ownerSessionId !== undefined && task.ownerSessionId !== coremateSessionId)) return null const stopping = pending || task.phase === 'stopping' return ( - + + {error === undefined ? null : {error}} + + ) } diff --git a/deepseek-harness-plugin/src/client/task-status-store.ts b/deepseek-harness-plugin/src/client/task-status-store.ts index 4007110..d63ccf5 100644 --- a/deepseek-harness-plugin/src/client/task-status-store.ts +++ b/deepseek-harness-plugin/src/client/task-status-store.ts @@ -10,6 +10,8 @@ export interface CoremateTaskSnapshot { } const IDLE: CoremateTaskStatus = { active: false, phase: 'idle', selectionLocked: false } +const STOP_TIMEOUT_MS = 5_000 +const STOP_REFRESH_TIMEOUT_MS = 1_000 export class CoremateTaskStatusStore { private snapshot: CoremateTaskSnapshot = { task: IDLE, launching: false } @@ -61,9 +63,31 @@ export class CoremateTaskStatusStore { } async stop(): Promise { - const response = await fetch(PHONE_TASK_STOP_PATH, { method: 'POST', headers: { Accept: 'application/json' } }) - if (!response.ok && response.status !== 409) throw new Error(`停止 OpenGUI 操作失败 (${response.status})`) - try { await this.refresh() } catch { /* polling retains the last truthful Host state */ } + const stopController = new AbortController() + const stopTimer = setTimeout(() => stopController.abort(new Error('停止 OpenGUI 操作超时,请检查 Host 后重试。')), STOP_TIMEOUT_MS) + try { + const response = await fetch(PHONE_TASK_STOP_PATH, { + method: 'POST', + headers: { Accept: 'application/json' }, + signal: stopController.signal, + }) + if (!response.ok && response.status !== 409) throw new Error(`停止 OpenGUI 操作失败 (${response.status})`) + } catch (error) { + if (stopController.signal.aborted) throw stopController.signal.reason + throw error + } finally { + clearTimeout(stopTimer) + } + + const refreshController = new AbortController() + const refreshTimer = setTimeout(() => refreshController.abort(), STOP_REFRESH_TIMEOUT_MS) + try { + await this.refresh(refreshController.signal) + } catch { + /* polling retains the last truthful Host state after an accepted stop */ + } finally { + clearTimeout(refreshTimer) + } } /** Reserve the short gap before the Host publishes the admitted task. */ diff --git a/deepseek-harness-plugin/src/index.ts b/deepseek-harness-plugin/src/index.ts index f6be85f..809ee7a 100644 --- a/deepseek-harness-plugin/src/index.ts +++ b/deepseek-harness-plugin/src/index.ts @@ -34,7 +34,7 @@ import type { FleetDevice } from './device-fleet.ts' import { OwnedForwardRegistry } from './forward-registry.ts' import { installMirrorHttp } from './mirror-http.ts' import { relayNestedTaskProgress, relayPhoneTaskProgress } from './phone-progress.ts' -import { OpenGuiTaskManager, OPENGUI_USAGE } from './phone-task.ts' +import { OpenGuiTaskManager, OPENGUI_USAGE, runPreparedOpenGuiTask } from './phone-task.ts' import type { CoremateTaskPresentation, CoremateTaskResult, OpenGuiTaskLease } from './phone-task.ts' import { resolveMobileProfile, type MobileApi } from './provider.ts' import { latestPhoneScreenshotMessages } from './runtime.ts' @@ -674,6 +674,7 @@ export function apply(ctx: Context, baseConfig: Config): void { const info = await ctx.llm.resolveModelInfo(route.provider, route.model, signal) modalities = info.inputModalities } catch (error) { + if (signal.aborted) throw signal.reason ctx.logger.debug(error instanceof Error ? error : new Error(String(error))) } const piAiDescriptor = ctx.settings.describe().find(item => item.ns === LLM_PI_AI_NS) @@ -715,6 +716,7 @@ export function apply(ctx: Context, baseConfig: Config): void { const info = await ctx.llm.resolveModelInfo(route.provider, route.model, signal) if (info.inputModalities?.includes('image')) return } catch (error) { + if (signal.aborted) throw signal.reason ctx.logger.debug(error instanceof Error ? error : new Error(String(error))) } if (Date.now() >= deadline) throw new Error('coremate-mobile: DSH 模型配置已保存,但热更新未及时生效;请重新提交任务') @@ -911,22 +913,17 @@ export function apply(ctx: Context, baseConfig: Config): void { const runRootTask = async ( interaction: TaskInteraction, operation: (lease: OpenGuiTaskLease) => Promise, - ): Promise => tasks.runRoot(interaction.agent, interaction.signal, 'waiting-for-device', async lease => { - const route = await prepareTask(interaction) - const targets = await waitForSelectedPhone(interaction) - lease.setPhase('routing') - lease.context = { targets, route } - lease.setPhase('running') - try { - return await operation(lease) - } catch (error) { - if (error instanceof Error) { - const recovered = await recoverTask(error, interaction, route) - if (recovered !== undefined) throw new Error(recovered) - } - throw error - } - }) + ): Promise => tasks.runRoot(interaction.agent, interaction.signal, 'waiting-for-device', lease => runPreparedOpenGuiTask( + interaction, + lease, + { + prepare: prepareTask, + waitForTargets: waitForSelectedPhone, + context: (route, targets) => ({ targets, route }), + execute: operation, + recover: recoverTask, + }, + )) const directCommand = (commandName: 'opengui' | 'coremate'): CommandDefinition => ({ name: commandName, diff --git a/deepseek-harness-plugin/src/phone-task.ts b/deepseek-harness-plugin/src/phone-task.ts index a53889a..7358742 100644 --- a/deepseek-harness-plugin/src/phone-task.ts +++ b/deepseek-harness-plugin/src/phone-task.ts @@ -146,14 +146,19 @@ export class CoremateTaskCoordinator { try { const result = await this.tasks.runRoot(invocation.agent, invocation.signal, 'waiting-for-device', async lease => { const agentOptions = await prepare?.({ ...invocation, signal: lease.signal }) + lease.signal.throwIfAborted() await preflight?.(invocation, lease.signal) + lease.signal.throwIfAborted() lease.setPhase('routing') lease.setPhase('running') + lease.signal.throwIfAborted() try { return await this.start(task, invocation.agent, lease.signal, 'parent-chat', agentOptions) } catch (error) { if (error instanceof Error && agentOptions !== undefined && recover !== undefined) { + lease.signal.throwIfAborted() const recovered = await recover(error, { ...invocation, signal: lease.signal }, agentOptions) + lease.signal.throwIfAborted() if (recovered !== undefined) throw new CoremateRecoveredError(recovered) } throw error @@ -193,6 +198,53 @@ export interface OpenGuiTaskLease { capabilityFailure(): Error | undefined } +/** Lifecycle hooks for preparing and executing one admitted OpenGUI root task. */ +export interface PreparedOpenGuiTaskHooks { + prepare(interaction: Interaction): Promise + waitForTargets(interaction: Interaction): Promise + context(route: Route, targets: Targets): Context + execute(lease: OpenGuiTaskLease): Promise + recover(error: Error, interaction: Interaction, route: Route): Promise +} + +/** + * Run every cancellable stage with the admitted task's fused lease signal. + * The caller's original signal alone cannot observe cancellation from the + * workbench stop control. + */ +export async function runPreparedOpenGuiTask< + Interaction extends { readonly signal: AbortSignal }, + Route, + Targets, + Context, + Result, +>( + interaction: Interaction, + lease: OpenGuiTaskLease, + hooks: PreparedOpenGuiTaskHooks, +): Promise { + const scopedInteraction = { ...interaction, signal: lease.signal } + const route = await hooks.prepare(scopedInteraction) + lease.signal.throwIfAborted() + const targets = await hooks.waitForTargets(scopedInteraction) + lease.signal.throwIfAborted() + lease.setPhase('routing') + lease.context = hooks.context(route, targets) + lease.setPhase('running') + lease.signal.throwIfAborted() + try { + return await hooks.execute(lease) + } catch (error) { + if (error instanceof Error) { + lease.signal.throwIfAborted() + const recovered = await hooks.recover(error, scopedInteraction, route) + lease.signal.throwIfAborted() + if (recovered !== undefined) throw new Error(recovered) + } + throw error + } +} + interface ActiveOpenGuiTask { readonly controller: AbortController readonly agents: WeakSet diff --git a/deepseek-harness-plugin/tests/bundle.spec.ts b/deepseek-harness-plugin/tests/bundle.spec.ts index 58d477c..ff8d41b 100644 --- a/deepseek-harness-plugin/tests/bundle.spec.ts +++ b/deepseek-harness-plugin/tests/bundle.spec.ts @@ -71,16 +71,23 @@ describe('standalone DeepSeek Harness bundle', () => { }) it('prepares the model before waiting for an explicit phone selection', async () => { - const [hostSource, configurationSource] = await Promise.all([ + const [hostSource, taskSource, configurationSource] = await Promise.all([ readFile(new URL('src/index.ts', root), 'utf8'), + readFile(new URL('src/phone-task.ts', root), 'utf8'), readFile(new URL('src/configuration.ts', root), 'utf8'), ]) - const prepareIndex = hostSource.indexOf('const route = await prepareTask(interaction)') - const deviceIndex = hostSource.indexOf('const targets = await waitForSelectedPhone(interaction)') + const prepareIndex = taskSource.indexOf('const route = await hooks.prepare(scopedInteraction)') + const deviceIndex = taskSource.indexOf('const targets = await hooks.waitForTargets(scopedInteraction)') expect(prepareIndex).toBeGreaterThan(-1) expect(deviceIndex).toBeGreaterThan(-1) expect(prepareIndex).toBeLessThan(deviceIndex) + expect(taskSource).toContain('const scopedInteraction = { ...interaction, signal: lease.signal }') + expect(taskSource.match(/lease\.signal\.throwIfAborted\(\)/g)).toHaveLength(10) + expect(hostSource).toContain('lease => runPreparedOpenGuiTask(') + expect(hostSource).toContain("const currentCapability = async (options: AgentOptions, signal: AbortSignal): Promise") + expect(hostSource).toContain("const waitForVisionDeclaration = async (route: ModelRoute, signal: AbortSignal): Promise") + expect(hostSource.match(/if \(signal\.aborted\) throw signal\.reason/g)?.length).toBeGreaterThanOrEqual(4) expect(hostSource).toContain('当前模型没有注明是否支持图片输入和工具调用。它是否具备这些能力?') expect(hostSource).toContain("status: 'cancelled' as const") expect(hostSource).toContain("status: 'completed' as const") @@ -95,6 +102,7 @@ describe('standalone DeepSeek Harness bundle', () => { expect(stopButtonSource).toContain("background: '#dc2626'") expect(stopButtonSource).not.toContain("background: 'currentColor'") + expect(stopButtonSource).toContain('role="alert"') }) it('renders a full embedded canvas, moves scenarios into @, and names the optional mirror an independent window', async () => { diff --git a/deepseek-harness-plugin/tests/phone-task.spec.ts b/deepseek-harness-plugin/tests/phone-task.spec.ts index 0355e9d..31856fb 100644 --- a/deepseek-harness-plugin/tests/phone-task.spec.ts +++ b/deepseek-harness-plugin/tests/phone-task.spec.ts @@ -1,7 +1,7 @@ import type { CommandInvocation } from '@deepseek-ai/dsh-commands' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' -import { CoremateTaskCoordinator, OpenGuiTaskManager } from '../src/phone-task.ts' +import { CoremateTaskCoordinator, OpenGuiTaskManager, runPreparedOpenGuiTask } from '../src/phone-task.ts' const invocation = (rawInput: string, signal = new AbortController().signal): CommandInvocation => ({ commandId: 'command-test' as CommandInvocation['commandId'], @@ -13,6 +13,79 @@ const invocation = (rawInput: string, signal = new AbortController().signal): Co const result = (runId: string, output: ContentBlock[] = [{ type: 'text', text: 'done' }]) => ({ runId, output }) describe('OpenGUI task entry points', () => { + it.each(['prepare', 'wait', 'recover'] as const)('cancels the lease-scoped %s phase', async (phase) => { + const manager = new OpenGuiTaskManager<{ route: string, targets: string[] }>() + const parentSignal = new AbortController().signal + let phaseSignal: AbortSignal | undefined + const waitForCancellation = (signal: AbortSignal): Promise => { + phaseSignal = signal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + } + const running = manager.runRoot(invocation('').agent, parentSignal, 'waiting-for-device', lease => runPreparedOpenGuiTask( + { agent: invocation('').agent, signal: parentSignal }, + lease, + { + prepare: interaction => phase === 'prepare' ? waitForCancellation(interaction.signal) : Promise.resolve('vision'), + waitForTargets: interaction => phase === 'wait' ? waitForCancellation(interaction.signal) : Promise.resolve(['phone-a']), + context: (route, targets) => ({ route, targets }), + execute: async () => { + if (phase === 'recover') throw new Error('model capability failed') + return 'done' + }, + recover: (_error, interaction) => phase === 'recover' ? waitForCancellation(interaction.signal) : Promise.resolve(undefined), + }, + )) + + await vi.waitFor(() => expect(phaseSignal).toBeDefined()) + expect(phaseSignal).not.toBe(parentSignal) + expect(manager.cancel()).toBe(true) + await expect(running).rejects.toThrow('OpenGUI task stopped by user') + expect(phaseSignal?.aborted).toBe(true) + expect(manager.state()).toEqual({ active: false, phase: 'idle', selectionLocked: false }) + }) + + it.each(['prepare', 'wait'] as const)('never advances after cancelled %s work settles late', async (phase) => { + const manager = new OpenGuiTaskManager<{ route: string, targets: string[] }>() + let release!: () => void + const late = new Promise(resolve => { release = resolve }) + const entered = vi.fn() + const waitForTargets = vi.fn(async () => { + if (phase === 'wait') { + entered() + await late + } + return ['phone-a'] + }) + const execute = vi.fn(async () => 'done') + const running = manager.runRoot(invocation('').agent, new AbortController().signal, 'waiting-for-device', lease => runPreparedOpenGuiTask( + { agent: invocation('').agent, signal: new AbortController().signal }, + lease, + { + prepare: async () => { + if (phase === 'prepare') { + entered() + await late + } + return 'vision' + }, + waitForTargets, + context: (route, targets) => ({ route, targets }), + execute, + recover: async () => undefined, + }, + )) + + await vi.waitFor(() => expect(entered).toHaveBeenCalledOnce()) + expect(manager.cancel()).toBe(true) + release() + + await expect(running).rejects.toThrow('OpenGUI task stopped by user') + if (phase === 'prepare') expect(waitForTargets).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + it('admits one root task while allowing only explicitly bound nested agents', async () => { const manager = new OpenGuiTaskManager<{ targets: string[] }>() let release!: () => void @@ -120,6 +193,40 @@ describe('OpenGUI task entry points', () => { expect(start).toHaveBeenCalledWith('open settings', call.agent, expect.any(AbortSignal), 'parent-chat', route) }) + it.each(['prepare', 'preflight', 'recover'] as const)('never advances after cancelled coordinator %s work settles late', async (phase) => { + let release!: () => void + const late = new Promise(resolve => { release = resolve }) + const entered = vi.fn() + const waitLate = async (): Promise => { + entered() + await late + } + const start = vi.fn(async () => { + if (phase === 'recover') throw new Error('model capability failed') + return result('run-late') + }) + const prepare = vi.fn(async () => { + if (phase === 'prepare') await waitLate() + return { provider: 'current', model: 'vision' } + }) + const preflight = vi.fn(async () => { + if (phase === 'preflight') await waitLate() + }) + const recover = vi.fn(async () => { + if (phase === 'recover') await waitLate() + return '已切换模型,请重新提交。' + }) + const coordinator = new CoremateTaskCoordinator(start) + const running = coordinator.command(preflight, prepare, recover).handler(invocation('检查手机')) + + await vi.waitFor(() => expect(entered).toHaveBeenCalledOnce()) + expect(coordinator.cancel()).toBe(true) + release() + + await expect(running).resolves.toEqual({ kind: 'error', text: 'coremate-mobile: OpenGUI task stopped by user' }) + if (phase !== 'recover') expect(start).not.toHaveBeenCalled() + }) + it('resolves the model before waiting for a phone and locks selection only afterwards', async () => { let allowPhone!: () => void const phoneReady = new Promise(resolve => { allowPhone = resolve }) diff --git a/deepseek-harness-plugin/tests/task-status-store.spec.ts b/deepseek-harness-plugin/tests/task-status-store.spec.ts index 9b6e55e..161c097 100644 --- a/deepseek-harness-plugin/tests/task-status-store.spec.ts +++ b/deepseek-harness-plugin/tests/task-status-store.spec.ts @@ -3,6 +3,7 @@ import { CoremateTaskStatusStore } from '../src/client/task-status-store.ts' afterEach(() => { vi.unstubAllGlobals() + vi.useRealTimers() }) describe('OpenGUI client task status store', () => { @@ -24,6 +25,37 @@ describe('OpenGUI client task status store', () => { }) }) + it('rejects an unresponsive Host stop request', async () => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + }))) + const store = new CoremateTaskStatusStore() + + const stopping = store.stop() + const result = expect(stopping).rejects.toThrow('停止 OpenGUI 操作超时,请检查 Host 后重试。') + await vi.advanceTimersByTimeAsync(5_000) + + await result + }) + + it('keeps an accepted stop successful when its best-effort refresh hangs', async () => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === '/coremate-mobile/task/stop') return Promise.resolve(Response.json({ accepted: true }, { status: 202 })) + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + }) + })) + const store = new CoremateTaskStatusStore() + + const stopping = store.stop() + const result = expect(stopping).resolves.toBeUndefined() + await vi.advanceTimersByTimeAsync(1_000) + + await result + }) + it('blocks duplicate launches until Host activity is observed', async () => { vi.stubGlobal('fetch', vi.fn(async () => Response.json({ active: true,