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
46 changes: 46 additions & 0 deletions docs/features/skill-draft-confirmation-card/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Plan

## Approach

1. Extend skill presenter with draft action helpers that can read, install, and delete a draft by conversation id and draft id.
2. When `skill_manage` `create` succeeds, include structured metadata in the tool raw result.
3. In agent runtime dispatch, detect successful draft creation and append a synthetic `question_request` action block using the existing question interaction flow.
4. Handle the synthetic interaction in `respondToolInteraction`:
- View: read draft content and keep the same card pending with content attached.
- Install: install draft folder into the configured skills directory, resolve the card, and update the `skill_manage` tool response.
- Discard: delete draft, resolve the card, and update the `skill_manage` tool response.
5. Update renderer question panel to render draft preview content and localized option labels.
6. Add/update tests for presenter draft actions, tool metadata, runtime interaction behavior, and renderer panel display.

## Affected Interfaces

- `SkillManageResult` gains optional `draftStatus` and supporting metadata only where useful.
- `ISkillPresenter` gains draft action methods scoped by conversation id/draft id.
- `MCPToolResponse.toolResult` carries structured draft result metadata for runtime-only detection.
- Question action block `extra` gains skill-draft-specific metadata fields.

## Data Flow

```text
Agent skill_manage create
-> SkillPresenter.manageDraftSkill creates temp draft
-> AgentToolManager returns rawData.toolResult
-> dispatch detects draft metadata
-> append question_request card
-> renderer shows ChatToolInteractionOverlay
-> user chooses option
-> AgentRuntimePresenter handles draft action
-> SkillPresenter view/install/delete draft
-> update card/tool response and resume as needed
```

## Compatibility

Existing question and permission blocks remain unchanged. Draft-specific behavior is guarded by `extra.skillDraftAction === 'confirm'` and the `skill_manage` tool name.

## Test Strategy

- Unit test draft action helpers in `skillPresenter`.
- Unit test `AgentToolManager` raw metadata for `skill_manage create`.
- Unit/integration test runtime draft confirmation interactions.
- Renderer component test for draft preview rendering in `ChatToolInteractionOverlay`.
41 changes: 41 additions & 0 deletions docs/features/skill-draft-confirmation-card/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Skill Draft Confirmation Card

## User Need

When the Agent creates a reusable skill draft after a task, users need an obvious in-chat confirmation card instead of having the draft disappear into a temp directory with no visible follow-up path.

## Goal

After a successful `skill_manage` draft creation, show a blocking question-style card in the chat:

- “已生成 skill draft:xxx”
- Options: 查看内容 / 安装为 Skill / 丢弃

The card should reuse the existing question interaction panel so it fits the current Agent interaction flow and pauses until the user chooses an action.

## Acceptance Criteria

1. A successful `skill_manage` `create` result produces an in-chat question interaction card before the Agent continues.
2. The card has three options: view content, install as Skill, discard.
3. Choosing “查看内容” shows the draft `SKILL.md` content and keeps the draft available for a later install/discard choice.
4. Choosing “安装为 Skill” installs the draft into the configured skills directory and resumes the Agent with a clear success/failure tool result.
5. Choosing “丢弃” deletes the draft and resumes the Agent with a clear result.
6. Existing `deepchat_question` interactions continue to work unchanged.
7. Draft install/delete operations remain scoped to the current conversation and opaque draft id.

## Constraints

- Follow existing presenter boundaries and typed route/contracts patterns where needed.
- Keep the UI change focused by reusing `ChatToolInteractionOverlay` and `question_request` blocks.
- Avoid exposing absolute temp paths to the renderer or the model.
- All user-facing strings must use i18n keys.

## Non-goals

- No full Drafts management list/page in Settings.
- No long-term draft persistence beyond the current temp draft retention policy.
- No automatic install without user confirmation.

## Open Questions

Resolved: Use the existing question panel and show a confirmation card immediately after draft creation.
10 changes: 10 additions & 0 deletions docs/features/skill-draft-confirmation-card/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Tasks

- [x] Add draft view/install/delete helpers to SkillPresenter.
- [x] Add structured draft metadata to skill_manage tool results.
- [x] Append synthetic draft confirmation question after successful draft create.
- [x] Handle view/install/discard responses in AgentRuntimePresenter.
- [x] Render draft preview content in question panel.
- [x] Add i18n strings.
- [x] Add/update tests.
- [ ] Run pnpm run format, pnpm run i18n, pnpm run lint.
111 changes: 109 additions & 2 deletions src/main/presenter/agentRuntimePresenter/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,23 @@ type StagedToolResult = {
rtkMode?: 'rewrite' | 'direct' | 'bypass'
rtkFallbackReason?: string
imagePreviews?: ToolCallImagePreview[]
skillDraftPrompt?: SkillDraftPromptPayload
postHookKind: 'success' | 'failure'
}

type SkillDraftPromptPayload = {
draftId: string
skillName: string
}

type SkillDraftToolResult = {
skillDraft?: {
status?: unknown
draftId?: unknown
skillName?: unknown
}
}

type ToolRunOutcome =
| {
kind: 'staged'
Expand Down Expand Up @@ -399,6 +413,28 @@ function extractSubagentToolState(rawData: MCPToolResponse): {
}
}

function extractSkillDraftPromptPayload(
rawData: MCPToolResponse
): SkillDraftPromptPayload | undefined {
const toolResult = rawData.toolResult as SkillDraftToolResult | null | undefined
const skillDraft = toolResult?.skillDraft
if (!skillDraft || typeof skillDraft !== 'object') {
return undefined
}
if (skillDraft.status !== 'created') {
return undefined
}
if (typeof skillDraft.draftId !== 'string' || typeof skillDraft.skillName !== 'string') {
return undefined
}
const draftId = skillDraft.draftId.trim()
const skillName = skillDraft.skillName.trim()
if (!draftId || !skillName) {
return undefined
}
return { draftId, skillName }
Comment on lines +416 to +435
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only accept draft prompts from the expected tool origin.

This trusts any tool result that happens to expose toolResult.skillDraft, then turns it into a privileged local draft action flow. A buggy or untrusted MCP tool can spoof this shape and surface view/install/discard against an arbitrary draft id. Gate the extraction on the executed tool name/source before enqueueing the question.

Suggested fix
-function extractSkillDraftPromptPayload(
-  rawData: MCPToolResponse
-): SkillDraftPromptPayload | undefined {
+function extractSkillDraftPromptPayload(
+  toolName: string,
+  rawData: MCPToolResponse
+): SkillDraftPromptPayload | undefined {
+  if (toolName !== 'skill_manage') {
+    return undefined
+  }
   const toolResult = rawData.toolResult as SkillDraftToolResult | null | undefined
   const skillDraft = toolResult?.skillDraft
   if (!skillDraft || typeof skillDraft !== 'object') {
     return undefined
   }
@@
-        skillDraftPrompt: extractSkillDraftPromptPayload(toolRawData),
+        skillDraftPrompt: extractSkillDraftPromptPayload(completedToolCall.name, toolRawData),

Also applies to: 1058-1058

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/presenter/agentRuntimePresenter/dispatch.ts` around lines 416 - 435,
The extractor currently accepts any MCPToolResponse that contains
toolResult.skillDraft; restrict it to only trust responses from the
known/trusted skill-draft tool by checking the tool identity first (e.g.,
compare rawData.toolName or rawData.toolExecution.name/id against a single
trusted constant like SKILL_DRAFT_TOOL_NAME or an allowlist) before reading
toolResult.skillDraft; if the tool identity does not match, return undefined.
Apply the same guard in the other occurrence referenced (around the other
extract call at the later location) so only the trusted tool can trigger the
draft prompt flow.

}

function shouldRefreshToolsAfterCall(toolName: string, rawData: MCPToolResponse): boolean {
if (toolName !== 'skill_view') {
return false
Expand Down Expand Up @@ -598,6 +634,21 @@ function applyFinalizedToolResults(params: {
imagePresentation.promotedBlocks
)

if (stagedResult.skillDraftPrompt && !fittedResult.isError && !fittedResult.downgraded) {
const interaction = appendSkillDraftQuestionActionBlock(
state,
io,
{
id: stagedResult.toolCallId,
name: stagedResult.toolName,
args: stagedResult.toolArgs,
serverName: stagedResult.serverName
},
stagedResult.skillDraftPrompt
)
state.pendingInteractions = [...(state.pendingInteractions ?? []), interaction]
}

if (fittedResult.isError) {
hooks?.onPostToolUseFailure?.({
callId: stagedResult.toolCallId,
Expand Down Expand Up @@ -733,7 +784,8 @@ function appendQuestionActionBlock(
serverIcons?: string
serverDescription?: string
},
question: NonNullable<PendingToolInteraction['question']>
question: NonNullable<PendingToolInteraction['question']>,
extra?: Record<string, unknown>
): PendingToolInteraction {
state.blocks.push({
type: 'action',
Expand All @@ -756,7 +808,8 @@ function appendQuestionActionBlock(
questionOptions: question.options,
questionMultiple: question.multiple,
questionCustom: question.custom,
questionResolution: 'asked'
questionResolution: 'asked',
...extra
}
})
state.dirty = true
Expand All @@ -773,6 +826,47 @@ function appendQuestionActionBlock(
}
}

function buildSkillDraftQuestion(
_payload: SkillDraftPromptPayload
): NonNullable<PendingToolInteraction['question']> {
return {
header: 'chat.skillDraft.confirmationTitle',
question: 'chat.skillDraft.confirmationQuestion',
options: [
{
label: 'chat.skillDraft.actions.view',
description: 'chat.skillDraft.actions.viewDescription'
},
{
label: 'chat.skillDraft.actions.install',
description: 'chat.skillDraft.actions.installDescription'
},
{
label: 'chat.skillDraft.actions.discard',
description: 'chat.skillDraft.actions.discardDescription'
}
],
custom: false,
multiple: false
}
}

function appendSkillDraftQuestionActionBlock(
state: StreamState,
io: IoParams,
toolContext: ToolExecutionContext['toolContext'],
payload: SkillDraftPromptPayload
): PendingToolInteraction {
const question = buildSkillDraftQuestion(payload)
return appendQuestionActionBlock(state, io, toolContext, question, {
skillDraftAction: 'confirm',
skillDraftId: payload.draftId,
skillDraftName: payload.skillName,
skillDraftPreview: '',
skillDraftStatus: 'pending'
})
}

function flushBlocksToRenderer(io: IoParams, blocks: AssistantMessageBlock[]): void {
const renderedBlocks = cloneBlocksForRenderer(blocks)
eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, {
Expand Down Expand Up @@ -961,6 +1055,7 @@ async function runToolCall(params: {
rtkMode: toolRawData.rtkMode,
rtkFallbackReason: toolRawData.rtkFallbackReason,
imagePreviews,
skillDraftPrompt: extractSkillDraftPromptPayload(toolRawData),
postHookKind: stagedIsError ? 'failure' : 'success'
},
toolsChanged: shouldRefreshToolsAfterCall(completedToolCall.name, toolRawData)
Expand Down Expand Up @@ -995,6 +1090,10 @@ export async function executeTools(
finalizePendingNarrativeBeforeToolExecution(state)
persistToolExecutionState(io, state, rendererFlushHandle)

if (state.pendingInteractions?.length) {
state.pendingInteractions = []
}

for (const tc of state.completedToolCalls) {
const toolDef = tools.find((t) => t.function.name === tc.name)
if (!toolDef) continue
Expand Down Expand Up @@ -1155,6 +1254,10 @@ export async function executeTools(
hooks,
appendToConversation: fittedResults.kind === 'ok'
})
if (state.pendingInteractions?.length) {
pendingInteractions.push(...state.pendingInteractions)
state.pendingInteractions = []
}
persistToolExecutionState(io, state, rendererFlushHandle)

if (fittedResults.kind === 'terminal_error') {
Expand Down Expand Up @@ -1316,6 +1419,10 @@ export async function executeTools(
hooks,
appendToConversation: fittedResults.kind === 'ok'
})
if (state.pendingInteractions?.length) {
pendingInteractions.push(...state.pendingInteractions)
state.pendingInteractions = []
}
persistToolExecutionState(io, state, rendererFlushHandle)

if (fittedResults.kind === 'terminal_error') {
Expand Down
Loading