Merge pull request #7153 from deepseek-harness/turtle/resume-blocked-goal-f1301e734784

fix(ui-goal): offer resume for a blocked goal
This commit is contained in:
Turtle authored and GitHub committed 2026-10-26 16:12:15 +08:00
commit 93fefc4fbd
7 files changed
+67 -14

No files matched your search

@@ -0,0 +1,4 @@
- text: Blocked Goal retry with the edited plan
- button "Resume goal"
- button "Edit goal"
- button "Clear goal"
+42 -2
View File
@@ -2,7 +2,7 @@
// The command creates a real projected goal in a real Host session. The
// goldens pin the active and disarmed strips, while the clear gesture proves
// the acknowledged tombstone leaves neither stale chrome nor a
// duplicate-mutation error.
// duplicate-mutation error. A blocked goal stays resumable after an edit.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -18,6 +18,7 @@ import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureS
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url))
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
const INACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'inactive.expected.md')
const BLOCKED_EXPECTED = join(SNAPSHOT_DIR, 'blocked.expected.md')
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
@@ -108,7 +109,46 @@ describe('web e2e: goal bar clear convergence', () => {
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('resumes a blocked goal after its objective is edited', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-blocked-resume'))
// `/goal <objective>` refuses to replace an unfinished goal, so start from none.
for (const existing of scaffold.ctx.agents.list()) {
const current = scaffold.ctx.goals.get(existing)
if (current !== undefined) scaffold.ctx.goals.clear(existing, current)
}
const input = page.locator('[data-composer-input]')
await input.fill('/goal retry after a blocker')
await input.press('Enter')
const bar = page.locator('[data-goal-bar]')
await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 })
const agents = scaffold.ctx.agents.list()
expect(agents).toHaveLength(1)
const agent = agents[0]!
const active = scaffold.ctx.goals.get(agent)!
scaffold.ctx.goals.block(agent, active, { code: 'stalled', message: 'No progress in 3 rounds' })
await bar.getByText('Blocked Goal').waitFor({ timeout: 10_000 })
expect(await bar.getByRole('button', { name: 'Resume goal' }).count()).toBe(1)
await bar.getByRole('button', { name: 'Edit goal' }).click()
await bar.getByRole('textbox', { name: 'Goal objective' }).fill('retry with the edited plan')
await bar.getByRole('button', { name: 'Save goal' }).click()
await bar.getByRole('button', { name: 'Edit goal' }).waitFor({ timeout: 10_000 })
await bar.getByText('retry with the edited plan').waitFor({ timeout: 10_000 })
expect(scaffold.ctx.goals.get(agent)?.phase).toBe('blocked')
const blocked = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BLOCKED_EXPECTED, blocked, MODE)
await bar.getByRole('button', { name: 'Resume goal' }).click()
await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 })
expect(scaffold.ctx.goals.get(agent)).toMatchObject({
phase: 'active', activation: 'armed', objective: 'retry with the edited plan',
})
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md', 'inactive.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md', 'blocked.expected.md', 'inactive.expected.md'])
})
})
+4 -4
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-goal/use-this-package:
en: 4f6e6353d793a0de
zh: cde6e470f7dd9057
en: 7f6620a84a087d07
zh: 80071126cc64b3e5
/deepseek-ai-dsh-client-ui-goal/use-this-package/the-command-input-bubble:
en: 05f0360b6c6dd3e9
zh: d14c3f15dc4cea5b
@@ -36,8 +36,8 @@
en: 68c9a2a057acb6cf
zh: baba87876e432666
/deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work:
en: bbb97ea1b94eae0f
zh: 8e6b0e0516746dcf
en: 7b69fe29ac31722e
zh: eef4e78fa12b4ff3
/deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+2 -1
View File
@@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce
<a id="use-this-package"></a>
## Use this package
Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up.
Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed, paused, or blocked goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up.
### The command-input bubble
@@ -79,6 +79,7 @@ None unless the queued goal context is admitted. An admitted context extends the
These limits define the current goal surface. They are current package constraints, not a goal-domain comparison or a task backlog.
- **Preset-independent host state** — switching an active session to `minimal` leaves its host-owned goal intact. `/goal` and goal tools disappear, while this strip can still edit, pause, resume, or clear the goal.
- **No round-budget control** — the strip cannot raise `maxGoalRounds`. Resuming a goal that used every round, such as one blocked by its round limit, fails with an inline error; ask the agent to raise the goal's round limit first.
<a id="dev-note"></a>
### Dev Note
+2 -1
View File
@@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
<a id="use-this-package"></a>
## 使用本包
与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。
与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed、paused 或 blocked 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。
### 指令输入气泡
@@ -79,6 +79,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
这些限制界定了当前 goal 界面。它们是当前包约束,不是 goal 领域对比或任务积压。
- **Host 状态与 preset 无关**——把活跃会话切换到 `minimal` 后,Host 拥有的 goal 仍会保留。`/goal` 与 goal 工具会消失,但该条带仍可编辑、暂停、恢复或清除 goal。
- **无轮次预算控制**——该条带无法提高 `maxGoalRounds`。恢复已用完全部轮次的 goal(例如因轮次上限而受阻的 goal)会失败,并显示行内错误;需先请 agent 提高该 goal 的轮次上限。
<a id="dev-note"></a>
### 开发备注
@@ -1,10 +1,10 @@
/**
* GoalBar: the goal indicator docked above the message composer (input dock
* strip). A present goal shows a goal glyph, a phase label, the truncated
* objective, and icon actions — resume when active-disarmed or paused, edit
* (inline form in the same strip), and clear. Goal creation lives on the
* `/goal` command, not here: loading (undefined), no goal (null), and complete
* goals render nothing. Durable state arrives as the projected whole snapshot;
* objective, and icon actions — resume when active-disarmed, paused, or
* blocked, edit (inline form in the same strip), and clear. Goal creation
* lives on the `/goal` command, not here: loading (undefined), no goal
* (null), and complete goals render nothing. Durable state arrives as the projected whole snapshot;
* process-local activation arrives through the injected activation hook.
*/
@@ -128,8 +128,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined
const label = goal.phase === 'active' ? activeLabel(activation, t) : t(PHASE_LABELS[goal.phase])
const showResume = goal.phase === 'paused'
|| (goal.phase === 'active' && activation === 'disarmed')
const showResume = goal.phase !== 'active' || activation === 'disarmed'
return (
<div className={css.dock} data-goal-bar>
<div className={css.bar} title={title}>
@@ -207,6 +207,14 @@ describe('GoalBar', () => {
expect(screen.getByText('受阻的目标').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
})
it('blocked goal: offers resume and no pause', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} t={t} />)
expect(screen.queryByRole('button', { name: '暂停目标' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
expect(actions.onResume).toHaveBeenCalledTimes(1)
})
it('blocked goal without a reason carries no tooltip', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} t={t} />)