Merge pull request #5942 from deepseek-harness/turtle/plan-mode-reject-33e1a9fe3ad2
fix(plan-mode): end the turn when the user dismisses a plan review
This commit is contained in:
commit
84f6a99514
26 files changed
+106
-64
No files matched your search
@@ -7,12 +7,14 @@
|
||||
// Replay is deterministic: the plan content arrives from replayed chunks, the
|
||||
// review wait is real, and the approve click is the test's own gesture (the
|
||||
// turn cannot complete without it, in record and replay alike).
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, ConsoleMessage, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
@@ -390,8 +392,12 @@ describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar
|
||||
|
||||
describe('web e2e: dismissed plan history', () => {
|
||||
it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => {
|
||||
// Replay supplies a finite continuation; the real question rejection and retained document are the assertions.
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false, paceMs: 15 })
|
||||
// A dismissed review concludes the turn, so only the call presenting the plan replays.
|
||||
const replayRoot = await mkdtemp(join(tmpdir(), 'dsh-plan-review-dismiss-replay-'))
|
||||
const replayOverride = join(replayRoot, 'replay.override.json')
|
||||
const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
|
||||
await writeFile(replayOverride, JSON.stringify(script.slice(0, 1)))
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride, compareReplaySession: false, paceMs: 15 })
|
||||
let browser: Browser | undefined
|
||||
const events: SessionEvent[] = []
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
@@ -417,9 +423,10 @@ describe('web e2e: dismissed plan history', () => {
|
||||
expect(call).toBeDefined()
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
const result = results.find(event => event.data.message.source.callId === call?.data.callId)
|
||||
expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: true })
|
||||
expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: false })
|
||||
expect(JSON.stringify(result)).toContain('dismissed the plan review')
|
||||
expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false)
|
||||
expect(events.filter(event => event.type === 'assistant/message')).toHaveLength(1)
|
||||
const modes = events.filter(event => event.type === 'plan/mode')
|
||||
expect(modes).toHaveLength(1)
|
||||
expect(modes[0]).toMatchObject({ data: { active: true } })
|
||||
@@ -443,6 +450,7 @@ describe('web e2e: dismissed plan history', () => {
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold.close()
|
||||
await rm(replayRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,8 @@
|
||||
en: ee04468005fae8e1
|
||||
zh: 1347d554102951f2
|
||||
/plan-mode/the-exit-tool-and-the-plan-command:
|
||||
en: ecf7fe520658b10f
|
||||
zh: 9d6d2c4729e9a47e
|
||||
en: b5bbc440552d7ade
|
||||
zh: 17fb1cfed1bc48ef
|
||||
/plan-mode/the-service:
|
||||
en: 07c3eec12e0dc75c
|
||||
zh: 183d73f6672e2c5a
|
||||
@@ -30,7 +30,7 @@ A missing, blank, or non-string `section` and any unknown key fail at plugin loa
|
||||
|
||||
## The exit tool and the `/plan` command
|
||||
|
||||
[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode.
|
||||
[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again. A dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. A missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode.
|
||||
|
||||
When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ interface PlanModeConfig {
|
||||
|
||||
## 退出工具与 `/plan` 命令
|
||||
|
||||
[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。
|
||||
[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。
|
||||
|
||||
当 [`ctx.commands`](commands.zh.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 2d300cdb883352ec
|
||||
zh: 5a81be005b3716d3
|
||||
/deepseek-ai-dsh-plan-mode/use-this-package/the-reviewed-exit:
|
||||
en: ffc56477380b9411
|
||||
zh: 17cac060f2252d78
|
||||
en: dbf12997d2359e81
|
||||
zh: 120a91554d2154f4
|
||||
/deepseek-ai-dsh-plan-mode/use-this-package/observing-plan-state:
|
||||
en: b44e92bc38406015
|
||||
zh: ed56190bee5d71a4
|
||||
@@ -90,8 +90,8 @@
|
||||
en: 9622e96bdc01276d
|
||||
zh: 53801e68a49f3d1d
|
||||
/deepseek-ai-dsh-plan-mode/model-experience/exit-tool-schema-and-review-exchange/what-the-model-sees:
|
||||
en: 3768d0189f9f13af
|
||||
zh: 2168a9b24551b7c6
|
||||
en: 16c57e8e64ad5a85
|
||||
zh: d81932553025c875
|
||||
/deepseek-ai-dsh-plan-mode/model-experience/exit-tool-schema-and-review-exchange/token-effect:
|
||||
en: d1b1418f20ee119d
|
||||
zh: c2b89ea3cae57175
|
||||
|
||||
@@ -60,7 +60,7 @@ You can attach images and generic files to a `/plan` message, and they are inclu
|
||||
|
||||
When the agent has a finished plan, it calls `exit_plan_mode` with the plan written as markdown and starting with a heading. You review that exact plan and choose `Approve` to leave plan mode, or `Keep planning` to send the agent back with feedback.
|
||||
|
||||
Choosing `Keep planning` (optionally with free-text feedback) sends the agent back to revise the plan; closing the review to type a message instead tells the agent to wait for your next message. If no interactive review is available, `exit_plan_mode` cannot run and you can still leave plan mode with `/plan off`.
|
||||
Choosing `Keep planning` (optionally with free-text feedback) sends the agent back to revise the plan; closing the review to type a message instead ends the agent's turn, and your next message starts the next request. If no interactive review is available, `exit_plan_mode` cannot run and you can still leave plan mode with `/plan off`.
|
||||
|
||||
### Observing plan state
|
||||
|
||||
@@ -164,7 +164,7 @@ The user block is append-only conversation growth. Entering or leaving plan mode
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover.
|
||||
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback. A dismissed review returns `{ approved: false }`, renders a note that the user will reply, and concludes the turn without another model request.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ kind: "package-reference"
|
||||
|
||||
agent 完成计划后,会以 markdown 形式、从标题开头书写计划并调用 `exit_plan_mode`。你评审该计划的原文,选择 `Approve` 离开计划模式,或选择 `Keep planning` 带反馈把 agent 送回去。
|
||||
|
||||
选择 `Keep planning`(可附自由文本反馈)会让 agent 回去修订计划;关闭评审改为发言,则告知 agent 等待你的下一条消息。若没有可用的交互评审,`exit_plan_mode` 无法运行,你仍可用 `/plan off` 离开计划模式。
|
||||
选择 `Keep planning`(可附自由文本反馈)会让 agent 回去修订计划;关闭评审改为发言,则结束 agent 当前轮次,你的下一条消息开始下一次请求。若没有可用的交互评审,`exit_plan_mode` 无法运行,你仍可用 `/plan off` 离开计划模式。
|
||||
|
||||
### 观察计划状态
|
||||
|
||||
@@ -164,7 +164,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
[`exit_plan_mode` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在计划模式之外执行会失败,而计划模式内经批准的评审返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃评审则是一次指明用户接手的失败调用。
|
||||
[`exit_plan_mode` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在计划模式之外执行会失败,而计划模式内经批准的评审返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用。放弃评审返回 `{ approved: false }`,渲染一条说明用户将自行回复的文本,并结束当前轮次,不再发起模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
|
||||
import { UserQuestionError, type AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions'
|
||||
import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
@@ -285,10 +285,15 @@ export class PlanModeController extends Service {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
approved: { type: 'boolean', const: true, required: true },
|
||||
approved: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
|
||||
render: (_args, { approved }) => [{
|
||||
type: 'text',
|
||||
text: approved
|
||||
? 'Plan approved — plan mode exited; carry out the plan starting with your next step.'
|
||||
: 'The user dismissed the plan review to reply in their own words; plan mode remains active.',
|
||||
}],
|
||||
},
|
||||
execute: async (args, exec) => {
|
||||
const agent = exec.agent
|
||||
@@ -303,35 +308,37 @@ export class PlanModeController extends Service {
|
||||
if (interaction === undefined) {
|
||||
throw new Error('no user-questions channel is available to review the plan; ask the user to switch the session mode instead')
|
||||
}
|
||||
const answer = await interaction.ask({
|
||||
questions: [{
|
||||
id: REVIEW_ID,
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: args.plan,
|
||||
options: [
|
||||
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
// Presentation only: a capable UI renders the plan as a review
|
||||
// decision instead of a generic question, and answers with one of
|
||||
// the labels above either way.
|
||||
intent: { kind: 'plan-review', approve: APPROVE_LABEL, callId: exec.callId },
|
||||
}],
|
||||
agent,
|
||||
signal: exec.signal,
|
||||
}).catch((cause: unknown) => {
|
||||
let answer: AskUserQuestionAnswer
|
||||
try {
|
||||
answer = await interaction.ask({
|
||||
questions: [{
|
||||
id: REVIEW_ID,
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: args.plan,
|
||||
options: [
|
||||
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
// Presentation only: a capable UI renders the plan as a review
|
||||
// decision instead of a generic question, and answers with one of
|
||||
// the labels above either way.
|
||||
intent: { kind: 'plan-review', approve: APPROVE_LABEL, callId: exec.callId },
|
||||
}],
|
||||
agent,
|
||||
signal: exec.signal,
|
||||
})
|
||||
} catch (cause: unknown) {
|
||||
// A dismissed review is not a failed one: the user took the turn back
|
||||
// to say something the two options do not cover. Say so, because the
|
||||
// generic channel message names ask_user_question, which the model
|
||||
// never called. An abort (turn cancel, provider teardown) keeps its
|
||||
// own message — there is no user to wait for.
|
||||
// to say something the two options do not cover, so the turn ends
|
||||
// here instead of spending a model request on waiting. An abort (turn
|
||||
// cancel, provider teardown) keeps its own failure.
|
||||
if (cause instanceof UserQuestionError && cause.code === 'ASK_CANCELLED') {
|
||||
throw new Error('The user dismissed the plan review to speak instead; '
|
||||
+ 'stay in plan mode, stop here, and wait for their message.')
|
||||
exec.concludeTurn()
|
||||
return { approved: false }
|
||||
}
|
||||
throw cause
|
||||
})
|
||||
}
|
||||
// A review may outlive this plugin fiber. Without its pre-step listener,
|
||||
// an approved selection could never be appended, so fail and keep planning.
|
||||
if (disposed) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import PlanModeController from '@deepseek-ai/dsh-plan-mode'
|
||||
import UserQuestionService, { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
|
||||
@@ -223,4 +224,29 @@ describe('plan mode through the agent loop', () => {
|
||||
{ type: 'text', text: 'The user switched this session to plan mode.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a dismissed plan review ends the turn without another model request', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-exit', 'exit_plan_mode', { plan: '# Plan\n\ndo things' }),
|
||||
// Consumed only if the dismissal regresses to another model request.
|
||||
textResponse('Waiting for your message.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
ctx.on('user-questions/request', () => Promise.reject(new UserQuestionError('the user cancelled ask_user_question', 'ASK_CANCELLED')))
|
||||
const agent = await ctx.agentLoop.create(SessionId('it-plan-dismiss'), { provider: 'mock', model: 'mock' })
|
||||
ctx.planMode.set(agent, true)
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan it' }], source: { kind: 'user' } }))
|
||||
await idle
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const log = agent.session.snapshotEvents()
|
||||
const result = findEvent(log, 'tool/result')
|
||||
expect(result.data.message.isError).toBe(false)
|
||||
expect(result.data.message.content).toEqual([{ type: 'text', text: 'The user dismissed the plan review to reply in their own words; plan mode remains active.' }])
|
||||
expect(findEvent(log, 'turn/end').data.reason).toEqual({ kind: 'completed' })
|
||||
expect(planActive(ctx, agent)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -165,7 +165,7 @@ function expectPlanPtcSdkBindings(sdk: string): void {
|
||||
expect(sdk).toContain('read: Record<string, JsonValue>;')
|
||||
expect(sdk).toContain('write: Record<string, JsonValue>;')
|
||||
expect(sdk).toContain('interface ToolOutputMap {')
|
||||
expect(sdk).toContain('exit_plan_mode: {\n approved: true;\n };')
|
||||
expect(sdk).toContain('exit_plan_mode: {\n approved: boolean;\n };')
|
||||
expect(sdk).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
|
||||
}
|
||||
|
||||
@@ -1058,7 +1058,7 @@ describe('exit_plan_mode', () => {
|
||||
expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)
|
||||
})
|
||||
|
||||
it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
|
||||
it('reads a dismissed review as the user taking the turn back: a turn-concluding success, not a failure', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
registerQuestionAnswerer(ctx, {
|
||||
ask: () => Promise.reject(Object.assign(
|
||||
@@ -1067,8 +1067,9 @@ describe('exit_plan_mode', () => {
|
||||
)),
|
||||
})
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }])
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.concludesTurn).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'The user dismissed the plan review to reply in their own words; plan mode remains active.' }])
|
||||
expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -375,7 +375,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -327,7 +327,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -356,7 +356,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -331,7 +331,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -331,7 +331,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -158,7 +158,7 @@ class ExitPlanModeArgs(TypedDict):
|
||||
# Additional keys beyond those declared are allowed.
|
||||
|
||||
class ExitPlanModeOutput(TypedDict):
|
||||
approved: Literal[True]
|
||||
approved: bool
|
||||
|
||||
class GetGoalOutput1(TypedDict):
|
||||
goal: None
|
||||
|
||||
@@ -327,7 +327,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -331,7 +331,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -429,7 +429,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -437,7 +437,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
@@ -420,7 +420,7 @@ interface ToolOutputMap {
|
||||
after: string;
|
||||
};
|
||||
exit_plan_mode: {
|
||||
approved: true;
|
||||
approved: boolean;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
|
||||
Reference in new issue
Block a user