Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions deepseek-harness-plugin/src/client/TaskStopButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 (
<button
type="button"
style={{ ...buttonStyle, cursor: stopping ? 'wait' : 'pointer', opacity: stopping ? 0.65 : 1 }}
disabled={stopping}
aria-label={error ?? (stopping ? '正在停止 OpenGUI 操作' : '停止 OpenGUI 操作')}
title={error ?? (stopping ? '正在停止 OpenGUI 操作…' : '停止 OpenGUI 操作')}
onClick={() => { void stop() }}
>
<span style={squareStyle} aria-hidden="true" />
</button>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{error === undefined ? null : <span role="alert" style={errorStyle}>{error}</span>}
<button
type="button"
style={{ ...buttonStyle, cursor: stopping ? 'wait' : 'pointer', opacity: stopping ? 0.65 : 1 }}
disabled={stopping}
aria-label={error ?? (stopping ? '正在停止 OpenGUI 操作' : '停止 OpenGUI 操作')}
title={error ?? (stopping ? '正在停止 OpenGUI 操作…' : '停止 OpenGUI 操作')}
onClick={() => { void stop() }}
>
<span style={squareStyle} aria-hidden="true" />
</button>
</span>
)
}
30 changes: 27 additions & 3 deletions deepseek-harness-plugin/src/client/task-status-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -61,9 +63,31 @@ export class CoremateTaskStatusStore {
}

async stop(): Promise<void> {
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. */
Expand Down
31 changes: 14 additions & 17 deletions deepseek-harness-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 模型配置已保存,但热更新未及时生效;请重新提交任务')
Expand Down Expand Up @@ -911,22 +913,17 @@ export function apply(ctx: Context, baseConfig: Config): void {
const runRootTask = async (
interaction: TaskInteraction,
operation: (lease: OpenGuiTaskLease<OpenGuiExecutionContext>) => Promise<CoremateTaskResult>,
): Promise<CoremateTaskResult> => tasks.runRoot<CoremateTaskResult>(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<CoremateTaskResult> => tasks.runRoot<CoremateTaskResult>(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,
Expand Down
52 changes: 52 additions & 0 deletions deepseek-harness-plugin/src/phone-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -193,6 +198,53 @@ export interface OpenGuiTaskLease<Context = unknown> {
capabilityFailure(): Error | undefined
}

/** Lifecycle hooks for preparing and executing one admitted OpenGUI root task. */
export interface PreparedOpenGuiTaskHooks<Interaction, Route, Targets, Context, Result> {
prepare(interaction: Interaction): Promise<Route>
waitForTargets(interaction: Interaction): Promise<Targets>
context(route: Route, targets: Targets): Context
execute(lease: OpenGuiTaskLease<Context>): Promise<Result>
recover(error: Error, interaction: Interaction, route: Route): Promise<string | undefined>
}

/**
* 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<Context>,
hooks: PreparedOpenGuiTaskHooks<Interaction, Route, Targets, Context, Result>,
): Promise<Result> {
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<Context> {
readonly controller: AbortController
readonly agents: WeakSet<object>
Expand Down
14 changes: 11 additions & 3 deletions deepseek-harness-plugin/tests/bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelCapability>")
expect(hostSource).toContain("const waitForVisionDeclaration = async (route: ModelRoute, signal: AbortSignal): Promise<void>")
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")
Expand All @@ -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 () => {
Expand Down
109 changes: 108 additions & 1 deletion deepseek-harness-plugin/tests/phone-task.spec.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand All @@ -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<never> => {
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<void>(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
Expand Down Expand Up @@ -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<void>(resolve => { release = resolve })
const entered = vi.fn()
const waitLate = async (): Promise<void> => {
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<void>(resolve => { allowPhone = resolve })
Expand Down
Loading
Loading