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:
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"
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
Reference in new issue
Block a user