From 9ad42bce35d89fff5dfc1676e2f853c734e4be04 Mon Sep 17 00:00:00 2001 From: HUANG Cheng Date: Wed, 9 Sep 2026 15:20:23 +0800 Subject: [PATCH 1/8] feat(chat): add Ask/Plan/Craft conversation modes via middleware Turn-scoped conversation_mode stamps tools and system hints through ConversationModeMiddleware (no harness kernel changes). Dashboard adds mode picker, PlanReady silent Craft handoff with plan_brief, and agent default mode on create/edit. --- CHANGELOG.md | 2 + dashboard/src/context/AgentContext.tsx | 2 + dashboard/src/locales/en.json | 13 ++ dashboard/src/locales/zh.json | 13 ++ .../src/pages/Chat/chatInputCore.partial.less | 54 +++++ .../pages/Chat/chatInputPickers.partial.less | 20 ++ .../src/pages/Chat/components/ChatInput.tsx | 6 + .../components/ChatInputActionsRow.test.tsx | 77 +++++-- .../Chat/components/ChatInputActionsRow.tsx | 104 ++++++++++ .../pages/Chat/components/MessageBubble.tsx | 9 +- .../Chat/components/PlanReadyCard.test.tsx | 22 ++ .../pages/Chat/components/PlanReadyCard.tsx | 49 +++++ dashboard/src/pages/Chat/hooks/chatStore.ts | 8 + dashboard/src/pages/Chat/hooks/sseHelpers.ts | 3 + dashboard/src/pages/Chat/hooks/useChat.ts | 49 ++++- .../Chat/hooks/useChatComposerResources.ts | 19 ++ dashboard/src/pages/Chat/hooks/useChatSend.ts | 32 ++- dashboard/src/pages/Chat/index.tsx | 65 ++++++ .../chatMessages.conversationMode.test.ts | 16 ++ .../src/pages/Chat/utils/chatMessages.ts | 5 + .../pages/Chat/utils/conversationMode.test.ts | 19 ++ .../src/pages/Chat/utils/conversationMode.ts | 24 +++ .../src/pages/Chat/utils/planArtifact.test.ts | 104 ++++++++++ .../src/pages/Chat/utils/planArtifact.ts | 121 +++++++++++ .../components/CreateFromExpertDrawer.tsx | 30 +++ .../Experts/components/EditAgentDrawer.tsx | 39 ++++ dashboard/src/pages/Experts/index.tsx | 2 + docs/api.md | 10 +- src/octop/api/routers/agents.py | 22 +- src/octop/api/routers/chat/models.py | 24 +++ src/octop/api/routers/chat/serialize.py | 3 + src/octop/api/routers/chat/turn.py | 104 +++++----- src/octop/api/routers/experts.py | 20 +- src/octop/i18n/domains/conversation.py | 47 +++++ src/octop/i18n/en.json | 9 + src/octop/i18n/zh.json | 9 + src/octop/infra/agents/conversation_mode.py | 113 ++++++++++ .../infra/agents/experts/market_creation.py | 3 + .../agents/experts/published_creation.py | 3 + src/octop/infra/agents/manager.py | 3 + .../agents/middleware/conversation_mode.py | 195 ++++++++++++++++++ src/octop/infra/agents/plan_artifact.py | 51 +++++ .../infra/gateway/process/message_keys.py | 2 + src/octop/infra/gateway/process/processor.py | 46 +++++ tests/unit/agents/test_agent_manager.py | 2 + tests/unit/agents/test_conversation_mode.py | 137 ++++++++++++ .../test_conversation_mode_middleware.py | 128 ++++++++++++ tests/unit/agents/test_plan_artifact.py | 27 +++ .../unit/api/test_agent_conversation_mode.py | 64 ++++++ tests/unit/api/test_chat_conversation_mode.py | 110 ++++++++++ .../test_processor_conversation_mode.py | 177 ++++++++++++++++ tests/unit/i18n/test_conversation.py | 58 ++++++ 52 files changed, 2187 insertions(+), 87 deletions(-) create mode 100644 dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx create mode 100644 dashboard/src/pages/Chat/components/PlanReadyCard.tsx create mode 100644 dashboard/src/pages/Chat/utils/chatMessages.conversationMode.test.ts create mode 100644 dashboard/src/pages/Chat/utils/conversationMode.test.ts create mode 100644 dashboard/src/pages/Chat/utils/conversationMode.ts create mode 100644 dashboard/src/pages/Chat/utils/planArtifact.test.ts create mode 100644 dashboard/src/pages/Chat/utils/planArtifact.ts create mode 100644 src/octop/i18n/domains/conversation.py create mode 100644 src/octop/infra/agents/conversation_mode.py create mode 100644 src/octop/infra/agents/middleware/conversation_mode.py create mode 100644 src/octop/infra/agents/plan_artifact.py create mode 100644 tests/unit/agents/test_conversation_mode.py create mode 100644 tests/unit/agents/test_conversation_mode_middleware.py create mode 100644 tests/unit/agents/test_plan_artifact.py create mode 100644 tests/unit/api/test_agent_conversation_mode.py create mode 100644 tests/unit/api/test_chat_conversation_mode.py create mode 100644 tests/unit/gateway/test_processor_conversation_mode.py create mode 100644 tests/unit/i18n/test_conversation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b89a5ad45..d5fea8b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - 知识库支持下载上传原文;PDF / DOCX / PPTX / XLSX 在预览弹窗中按原排版查看;文档区支持拖拽上传(#591) - 知识库 / 工作区 PDF 预览改用 PDF.js(react-pdf),替代浏览器内置 iframe 查看器 - 技能包支持将选定技能一次性复制到专家工作区,工作区技能也可推送到当前用户有写权限的技能包(#618)。 +- 对话权限模式 Ask / Plan / Craft:turn 级 `conversation_mode`、`ConversationModeMiddleware`(工具过滤 + 系统提示 + denylist 硬拒;Ask/Plan 额外拦截 `task` / `acp_runner` / `ask_agent` 防委派逃逸)、agent `default_conversation_mode`;Composer 模式菜单(默认 / 计划 / 仅问答)(#616) +- 想一想模式回合结束后展示「按计划执行 / 继续改计划」;确认后切到默认模式,计划 brief 经 `plan_brief` 由网关注入 system(不落对话气泡)(#616) - 记忆树支持手动新建主题/记忆,以及编辑已有记忆(replace/supersede,保留变更历史)。 - Token 统计支持日期范围筛选(RangePicker 预设 + 自定义起止)并导出 Excel 用量明细(#154) - Token 用量 Excel 含专家名称、中英列表头,以及按天/专家/模型汇总 sheet 与图表 diff --git a/dashboard/src/context/AgentContext.tsx b/dashboard/src/context/AgentContext.tsx index 9bdac26bf..4b2b6a982 100644 --- a/dashboard/src/context/AgentContext.tsx +++ b/dashboard/src/context/AgentContext.tsx @@ -37,6 +37,8 @@ export interface OctopAgent { description: string | null; persona_mbti: string | null; default_model: string | null; + /** Composer default: ask | plan | craft (#616). Missing → craft. */ + default_conversation_mode?: "ask" | "plan" | "craft" | null; system_prompt: string | null; template_name: string | null; state: "running" | "stopped" | "failed" | "starting" | "stopping" | string; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 59ae30169..63591c132 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -811,6 +811,8 @@ "patchFailed": "Failed to save changes", "defaultModelLabel": "Default Model", "defaultModelPlaceholder": "Select a model", + "defaultConversationModeLabel": "Default conversation mode", + "defaultConversationModeHint": "Initial permission mode for new chats; change anytime in the composer. Does not limit which knowledge bases the expert can search.", "skillPackagesLabel": "Optional Skill Packages", "skillPackagesHint": "Mount packages at create time (local storage with root /).", "skillPackagesUnsupportedHint": "This sandbox cannot mount skill-package directories directly. You can still use Copy skills to create independent workspace copies.", @@ -1109,6 +1111,17 @@ "reasoningDisabled": "Off", "reasoningEffort": "Thinking effort", "reasoningAlways": "Always reasoning", + "conversationMode": "Mode", + "conversationModeAsk": "Ask only", + "conversationModePlan": "Plan", + "conversationModeCraft": "Default", + "conversationModeStatusCraft": "Default mode — execute efficiently and finish the task.", + "conversationModeStatusPlan": "Plan mode — outline the approach first, then execute.", + "conversationModeStatusAsk": "Ask-only mode — answer and analyze without changing the environment.", + "planReadyTitle": "Ready to build?", + "planReadyHint": "Switch to Default mode and continue with this plan, or keep refining it.", + "planReadyExecute": "Execute plan", + "planReadyContinue": "Keep planning", "errorOccurred": "An error occurred", "imageLoadFailed": "Image failed to load", "mediaLoadFailed": "Unable to load media", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 49beef4bf..8b512ec05 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -811,6 +811,8 @@ "agentDisable": "停用", "defaultModelLabel": "默认模型", "defaultModelPlaceholder": "选择一个模型", + "defaultConversationModeLabel": "默认对话模式", + "defaultConversationModeHint": "新对话的初始权限模式;可在聊天输入栏随时切换。不限制可检索的知识库范围。", "skillPackagesLabel": "可选技能包", "skillPackagesHint": "创建时挂载选中的技能包(需本地存储,且根目录为 /)。", "skillPackagesUnsupportedHint": "当前沙箱无法直接挂载技能包目录,但仍可使用“复制技能”在工作区创建独立副本。", @@ -1109,6 +1111,17 @@ "reasoningDisabled": "关闭", "reasoningEffort": "思考强度", "reasoningAlways": "始终推理", + "conversationMode": "模式", + "conversationModeAsk": "仅问答", + "conversationModePlan": "计划", + "conversationModeCraft": "默认", + "conversationModeStatusCraft": "当前为默认模式,可高效执行并完成任务。", + "conversationModeStatusPlan": "当前为计划模式,先梳理方案与步骤,再动手执行。", + "conversationModeStatusAsk": "当前为仅问答模式,只回答与分析,不改动环境。", + "planReadyTitle": "按计划执行?", + "planReadyHint": "确认后将切换到默认模式并带着这份计划继续执行;也可继续改计划。", + "planReadyExecute": "按计划执行", + "planReadyContinue": "继续改计划", "errorOccurred": "出现错误", "imageLoadFailed": "图片加载失败", "mediaLoadFailed": "无法加载媒体", diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less index eba3f8f6e..cd0d2f69e 100644 --- a/dashboard/src/pages/Chat/chatInputCore.partial.less +++ b/dashboard/src/pages/Chat/chatInputCore.partial.less @@ -28,6 +28,60 @@ } } +.planReadyCard { + max-width: var(--chat-column-max, 960px); + width: calc(100% - 48px); + margin: 0 auto 10px; + padding: 12px 14px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 12px; + background: var(--fn-bg-elevated, #fff); + + @media (max-width: 767px) { + width: calc(100% - 24px); + } +} + +.planReadyTitle { + font-size: 14px; + font-weight: 600; + color: var(--fn-text-primary); + margin-bottom: 4px; +} + +.planReadyHint { + margin: 0 0 10px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-secondary); +} + +.planReadyActions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.planReadyPrimary, +.planReadySecondary { + border-radius: 8px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} + +.planReadyPrimary { + border: none; + background: var(--fn-brand, #1677ff); + color: #fff; +} + +.planReadySecondary { + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.12)); + background: transparent; + color: var(--fn-text-primary); +} + .askQuestionDockInner { width: 100%; max-width: var(--chat-column-max, 960px); diff --git a/dashboard/src/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less index 7bb55a73f..bcc6bd39e 100644 --- a/dashboard/src/pages/Chat/chatInputPickers.partial.less +++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less @@ -163,6 +163,26 @@ padding-left: 6px; } +.conversationModeMenuPanel { + width: 260px; + min-width: 260px; + max-height: none; +} + +.conversationModeStatus { + padding: 8px 10px 6px; + color: var(--fn-text-tertiary); + font-size: 12px; + line-height: 1.45; +} + +.conversationModeChoiceLabel { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + .reasoningMenuHeader { display: flex; align-items: center; diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index f5318ba5d..908c494b8 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -80,6 +80,8 @@ interface ChatInputProps { mode: "auto" | "enabled" | "disabled", effort: string | null, ) => void; + conversationMode?: "ask" | "plan" | "craft"; + onConversationModeChange?: (mode: "ask" | "plan" | "craft") => void; availableConnectors?: { mcp_server_name: string; label: string; @@ -137,6 +139,8 @@ const ChatInput = forwardRef( reasoningMode = "auto", reasoningEffort = null, onReasoningChange, + conversationMode = "craft", + onConversationModeChange, availableConnectors, selectedConnectors = [], onConnectorsChange, @@ -767,6 +771,8 @@ const ChatInput = forwardRef( reasoningMode={reasoningMode} reasoningEffort={reasoningEffort} onReasoningChange={onReasoningChange} + conversationMode={conversationMode} + onConversationModeChange={onConversationModeChange} defaultModel={defaultModel} availableConnectors={availableConnectors} selectedConnectors={selectedConnectors} diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index 9a0d977ec..3e134cf04 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -1,4 +1,6 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import type { ResolvedModel } from "../../../api/types"; import ChatInputActionsRow from "./ChatInputActionsRow"; @@ -18,29 +20,37 @@ const models: ResolvedModel[] = [ }, ]; +const baseProps = { + isMobile: false, + isStreaming: false, + canSend: false, + text: "", + polishing: false, + uploading: false, + recording: false, + transcribing: false, + slashPickerGroups: null as null, + slashMenuItems: [] as [], + onSlashShortcutSelect: vi.fn(), + onFileSelect: vi.fn(), + onNewChat: vi.fn(), + onPolish: vi.fn(), + onToggleVoice: vi.fn(), + onCancel: vi.fn(), + onSubmit: vi.fn(), +}; + +function renderRow(ui: ReactElement) { + return render({ui}); +} + describe("ChatInputActionsRow compact pickers", () => { it("uses a popover instead of a full-width drawer on narrow desktop", async () => { - const { container } = render( + const { container } = renderRow( , ); @@ -57,3 +67,32 @@ describe("ChatInputActionsRow compact pickers", () => { expect(document.querySelector(".ant-drawer-content")).toBeNull(); }); }); + +describe("ChatInputActionsRow conversation mode (#616 M13)", () => { + it("shows status + Default/Plan/Ask labels and notifies on Ask select", async () => { + const onConversationModeChange = vi.fn(); + renderRow( + , + ); + + fireEvent.click(screen.getByTestId("conversation-mode-trigger")); + + await waitFor(() => { + expect( + screen.getAllByTestId("conversation-mode-menu").length, + ).toBeGreaterThan(0); + }); + const menu = screen.getAllByTestId("conversation-mode-menu")[0]!; + expect(menu).toHaveTextContent("当前为默认模式"); + expect(menu).toHaveTextContent("默认"); + expect(menu).toHaveTextContent("计划"); + expect(menu).toHaveTextContent("仅问答"); + + fireEvent.click(screen.getAllByTestId("conversation-mode-option-ask")[0]!); + expect(onConversationModeChange).toHaveBeenCalledWith("ask"); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index 98e9f94fb..6c9da1cee 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -44,6 +44,10 @@ import type { SlashMenuItem } from "../hooks/useSlashMentionInput"; import { SHORTCUT_ICON_TONE_CLASS } from "../utils/slashShortcutStyles"; import { isSttAvailable } from "../../../hooks/useVoiceInput"; import { resolveTurnModelOverride } from "../utils/chatMessages"; +import { + COMPOSER_CONVERSATION_MODES, + type ConversationMode, +} from "../utils/conversationMode"; import styles from "../index.module.less"; /** Shared by mobile drawers and narrow-desktop popovers. */ @@ -88,6 +92,8 @@ interface ChatInputActionsRowProps { mode: "auto" | "enabled" | "disabled", effort: string | null, ) => void; + conversationMode?: "ask" | "plan" | "craft"; + onConversationModeChange?: (mode: "ask" | "plan" | "craft") => void; availableConnectors?: { mcp_server_name: string; label: string; @@ -142,6 +148,8 @@ export default function ChatInputActionsRow({ reasoningMode = "auto", reasoningEffort = null, onReasoningChange, + conversationMode = "craft", + onConversationModeChange, availableConnectors, selectedConnectors = [], onConnectorsChange, @@ -172,6 +180,7 @@ export default function ChatInputActionsRow({ const [expertPickerOpen, setExpertPickerOpen] = useState(false); const [connectorPickerOpen, setConnectorPickerOpen] = useState(false); const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false); + const [conversationModeOpen, setConversationModeOpen] = useState(false); const [shortcutOpen, setShortcutOpen] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [reasoningModelRef, setReasoningModelRef] = useState( @@ -289,6 +298,72 @@ export default function ChatInputActionsRow({ setReasoningModelRef(modelRef); }; + const conversationModeLabel = (mode: ConversationMode) => { + if (mode === "ask") return t("chat.conversationModeAsk", "仅问答"); + if (mode === "plan") return t("chat.conversationModePlan", "计划"); + return t("chat.conversationModeCraft", "默认"); + }; + + const conversationModeStatus = (mode: ConversationMode) => { + if (mode === "ask") { + return t( + "chat.conversationModeStatusAsk", + "当前为仅问答模式,只回答与分析,不改动环境。", + ); + } + if (mode === "plan") { + return t( + "chat.conversationModeStatusPlan", + "当前为计划模式,先梳理方案与步骤,再动手执行。", + ); + } + return t( + "chat.conversationModeStatusCraft", + "当前为默认模式,可高效执行并完成任务。", + ); + }; + + const conversationModeIcon = (mode: ConversationMode) => { + if (mode === "ask") return ; + if (mode === "plan") return ; + return ; + }; + + const conversationModeMenu = ( +
+
+ {conversationModeStatus(conversationMode)} +
+
+ {COMPOSER_CONVERSATION_MODES.map((mode) => ( + + ))} +
+ ); + const reasoningMenu = reasoningModelCapability ? (
@@ -1144,6 +1219,35 @@ export default function ChatInputActionsRow({ )} + {onConversationModeChange && ( + + + + + + )} {isStreaming ? ( canSend ? ( diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx index 76b12ece4..b84245f04 100644 --- a/dashboard/src/pages/Chat/components/MessageBubble.tsx +++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx @@ -19,6 +19,7 @@ import type { ChatAttachment, ChatMessage } from "../hooks/useChat"; import type { ComposerTagLookups } from "./UserMessageComposerTags"; import UserMessageComposerTags from "./UserMessageComposerTags"; import { deriveMessageContent } from "../utils/messageContent"; +import { stripConversationModeUiInstructions } from "../utils/planArtifact"; import { inferKindFromNameAndMime } from "../utils/chatAttachments"; import { ChatMediaPlayer } from "./ChatMediaPlayer"; import { useAuthImageSrc } from "../../../hooks/useAuthImageSrc"; @@ -573,10 +574,16 @@ function MessageBubble({ [message.errorInfo], ); - const { textContent } = useMemo( + const { textContent: rawTextContent } = useMemo( () => deriveMessageContent(message), [message], ); + const textContent = useMemo(() => { + if (message.role === "user" || message.toolData || message.hitlData) { + return rawTextContent; + } + return stripConversationModeUiInstructions(rawTextContent); + }, [message.role, message.toolData, message.hitlData, rawTextContent]); const speechText = useMemo( () => prepareSpeechText(textContent), [textContent], diff --git a/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx b/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx new file mode 100644 index 000000000..0e3b74c52 --- /dev/null +++ b/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import PlanReadyCard from "./PlanReadyCard"; + +describe("PlanReadyCard (#616 P6–P8)", () => { + it("shows execute and continue actions", () => { + const onExecute = vi.fn(); + const onContinue = vi.fn(); + render( + , + ); + expect(screen.getByTestId("plan-ready-card")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("plan-ready-execute")); + expect(onExecute).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByTestId("plan-ready-continue")); + expect(onContinue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dashboard/src/pages/Chat/components/PlanReadyCard.tsx b/dashboard/src/pages/Chat/components/PlanReadyCard.tsx new file mode 100644 index 000000000..f64ebd184 --- /dev/null +++ b/dashboard/src/pages/Chat/components/PlanReadyCard.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from "react-i18next"; +import styles from "../index.module.less"; + +interface PlanReadyCardProps { + brief: string; + onExecute: () => void; + onContinue: () => void; +} + +/** Cursor/WorkBuddy-style Plan → Craft checkpoint (#616 P6). */ +export default function PlanReadyCard({ + brief, + onExecute, + onContinue, +}: PlanReadyCardProps) { + const { t } = useTranslation(); + void brief; + return ( +
+
+ {t("chat.planReadyTitle", "按计划执行?")} +
+

+ {t( + "chat.planReadyHint", + "确认后将切换到默认模式并带着这份计划继续执行;也可继续改计划。", + )} +

+
+ + +
+
+ ); +} diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 5eb443877..bc26ea335 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -1829,6 +1829,8 @@ async function sendTurnWebSocket( onStreamEnd?: () => void, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: "ask" | "plan" | "craft", + planBrief?: string, ): Promise { const state = getOrCreate(sessionId); const resolvedThreadId = (threadId || sessionId).trim(); @@ -1907,6 +1909,8 @@ async function sendTurnWebSocket( } if (reasoningMode) payload.reasoning_mode = reasoningMode; if (reasoningEffort) payload.reasoning_effort = reasoningEffort; + if (conversationMode) payload.conversation_mode = conversationMode; + if (planBrief?.trim()) payload.plan_brief = planBrief.trim(); ws.send(JSON.stringify(payload)); }; @@ -2032,6 +2036,8 @@ export async function sendTurn( targetAgentIds?: string[] | null, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: "ask" | "plan" | "craft", + planBrief?: string, ): Promise { const state = getOrCreate(sessionId); @@ -2104,6 +2110,8 @@ export async function sendTurn( onStreamEnd, reasoningMode, reasoningEffort, + conversationMode, + planBrief, ); if (!wsOk) { state.messages = [ diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index e6711a3c9..403401e78 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -53,6 +53,7 @@ export interface UserComposerContext { model?: string; reasoningMode?: "auto" | "enabled" | "disabled"; reasoningEffort?: string | null; + conversationMode?: "ask" | "plan" | "craft"; } export interface ChatMessage { @@ -71,6 +72,8 @@ export interface ChatMessage { errorInfo?: ProcessErrorInfo; status?: "streaming" | "done" | "error"; timestamp: number; + /** Plan→Craft silent handoff — omit from chat transcript UI. */ + uiHidden?: boolean; } /** Per-session state held in the chat store's module-scoped Map. */ diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index a305f937f..4f6e946a0 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -24,6 +24,7 @@ import { type ContentBlock, } from "../../../utils/messageParser"; import { normalizeComposerContext } from "../utils/chatMessages"; +import { PLAN_EXECUTE_USER_TRIGGER } from "../utils/planArtifact"; import { resolveMessageTimestampMs } from "../../../utils/formatMessageTime"; import { inferKindFromNameAndMime } from "../utils/chatAttachments"; import { @@ -442,6 +443,12 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { ?.composer_context, ) : undefined, + uiHidden: + entry.role === "user" && + Boolean( + (entry.metadata as Record | null | undefined) + ?.ui_hidden, + ), toolData: tool?.data, usage: normalizeTokenUsage(entry.usage ?? undefined) ?? undefined, metadata: normalizeMessageMetadata(entry.metadata ?? undefined), @@ -627,6 +634,15 @@ function toHistoryContentBlocks(content: unknown): unknown[] { } function isDisplayableHistoryMessage(message: ChatMessage): boolean { + if (message.uiHidden) return false; + // Silent Plan→Craft trigger (in case older servers omitted ui_hidden). + if ( + message.role === "user" && + message.content.trim() === PLAN_EXECUTE_USER_TRIGGER && + !(message.attachments && message.attachments.length > 0) + ) { + return false; + } if (message.hitlData) return true; if (message.toolData) return true; if (message.attachments && message.attachments.length > 0) return true; @@ -643,6 +659,7 @@ export function convertHistoryMessages( timestamp?: number; composer_context?: unknown; inbound_attachments?: unknown; + ui_hidden?: unknown; }>, agentId?: string, ): ChatMessage[] { @@ -654,6 +671,9 @@ export function convertHistoryMessages( if (message.inbound_attachments) { meta.inbound_attachments = message.inbound_attachments; } + if (message.ui_hidden) { + meta.ui_hidden = true; + } return { message_id: message.id, entry_index: `hist-${index}`, @@ -818,20 +838,25 @@ export function useChat( composerContext?: UserComposerContext, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: "ask" | "plan" | "craft", + planBrief?: string, + hideUserMessage?: boolean, ) => { const key = storeKey || stableSessionId; - const userMsg: ChatMessage = { - id: generateId(), - role: "user", - content: text, - attachments: - attachments && attachments.length > 0 ? attachments : undefined, - composerContext, - status: "done", - timestamp: Date.now(), - }; - chatStore.appendUserMessage(key, userMsg); + if (!hideUserMessage) { + const userMsg: ChatMessage = { + id: generateId(), + role: "user", + content: text, + attachments: + attachments && attachments.length > 0 ? attachments : undefined, + composerContext, + status: "done", + timestamp: Date.now(), + }; + chatStore.appendUserMessage(key, userMsg); + } const threadIdForApi = storeKey || @@ -851,6 +876,8 @@ export function useChat( targetAgentIds, reasoningMode, reasoningEffort, + conversationMode, + planBrief, ); }, [stableSessionId], diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index d3e7a76f7..9a0a56340 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -27,6 +27,10 @@ import { } from "../utils/pendingAttachKnowledgeBase"; import { withDefaultOpenKnowledgeBases } from "../utils/withDefaultOpenKnowledgeBases"; import { isPendingThread } from "./useSessions"; +import { + parseConversationMode, + type ConversationMode, +} from "../utils/conversationMode"; export function useChatComposerResources( resolvedAgentId: string | null | undefined, @@ -35,6 +39,7 @@ export function useChatComposerResources( stickyModel?: string | null, stickyReasoningMode?: "auto" | "enabled" | "disabled" | null, stickyReasoningEffort?: string | null, + defaultConversationMode?: ConversationMode | string | null, ) { const user = useCurrentUser(); const currentUserId = user?.id ?? null; @@ -68,6 +73,9 @@ export function useChatComposerResources( "auto" | "enabled" | "disabled" >("auto"); const [reasoningEffort, setReasoningEffort] = useState(null); + const [conversationMode, setConversationMode] = useState( + () => parseConversationMode(defaultConversationMode), + ); const [conversationOverrides, setConversationOverrides] = useState< Record< string, @@ -79,6 +87,11 @@ export function useChatComposerResources( > >({}); + // Seed Ask/Craft from agent default when the expert changes (M14). + useEffect(() => { + setConversationMode(parseConversationMode(defaultConversationMode)); + }, [resolvedAgentId, defaultConversationMode]); + // Auto = omit turn model; backend applies the expert default. useEffect(() => { const local = activeThreadId @@ -313,6 +326,10 @@ export function useChatComposerResources( setSelectedKnowledgeBaseIds(ids); }, []); + const handleConversationModeChange = useCallback((mode: ConversationMode) => { + setConversationMode(mode); + }, []); + const handleModelChange = useCallback( (model: string | null) => { setSelectedModel(model); @@ -383,6 +400,8 @@ export function useChatComposerResources( reasoningMode, reasoningEffort, handleReasoningChange, + conversationMode, + handleConversationModeChange, selectedConnectors, selectedSkills, selectedKnowledgeBaseIds, diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts index 7ef5dd5f2..4ceff8cf4 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSend.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts @@ -13,6 +13,7 @@ import { buildUserMessage, resolveTurnModelRef, } from "../utils/chatMessages"; +import type { ConversationMode } from "../utils/conversationMode"; interface UseChatSendParams { resolvedAgentId: string | null | undefined; @@ -26,6 +27,7 @@ interface UseChatSendParams { selectedTargetAgents: string[]; reasoningMode: "auto" | "enabled" | "disabled"; reasoningEffort: string | null; + conversationMode: ConversationMode; defaultModel?: string | null; sendMessage: ( text: string, @@ -41,6 +43,9 @@ interface UseChatSendParams { composerContext?: UserComposerContext, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + conversationMode?: ConversationMode, + planBrief?: string, + hideUserMessage?: boolean, ) => void; createSession: () => { session: Session; resolvedId: Promise }; renameSession: (id: string, name: string) => void; @@ -62,6 +67,11 @@ export type ChatSendOverrides = { selectedTargetAgents?: string[]; composerContext?: UserComposerContext; modelRef?: string | null; + conversationMode?: ConversationMode; + /** Internal plan brief for Craft handoff — not shown as chat text. */ + planBrief?: string; + /** Do not append a local user bubble (PlanReady execute). */ + hideUserMessage?: boolean; /** Send to this thread instead of the active one (queued flush). */ threadId?: string | null; /** Send as this agent instead of the active one (queued flush). */ @@ -80,6 +90,7 @@ export function useChatSend({ selectedTargetAgents, reasoningMode, reasoningEffort, + conversationMode, defaultModel, sendMessage, createSession, @@ -103,9 +114,14 @@ export function useChatSend({ } const trimmed = text.trim(); - if (!trimmed && !(attachments && attachments.length > 0)) return false; + const planBrief = overrides?.planBrief?.trim() || ""; + const hideUserMessage = Boolean(overrides?.hideUserMessage); + if (!trimmed && !(attachments && attachments.length > 0) && !planBrief) { + return false; + } const maybeRenameNewThread = (tid: string, hadMessages: boolean) => { + if (!trimmed) return; const current = sessions.find((s) => s.id === tid); if (current?.name === "New Chat" && !hadMessages) { renameSession(tid, deriveThreadTitle(trimmed)); @@ -135,6 +151,10 @@ export function useChatSend({ overrides?.composerContext?.reasoningMode ?? reasoningMode, reasoningEffort: overrides?.composerContext?.reasoningEffort ?? reasoningEffort, + conversationMode: + overrides?.composerContext?.conversationMode ?? + overrides?.conversationMode ?? + conversationMode, }); const modelOverride = @@ -158,6 +178,9 @@ export function useChatSend({ composerContext, composerContext?.reasoningMode ?? reasoningMode, composerContext?.reasoningEffort ?? reasoningEffort, + composerContext?.conversationMode ?? conversationMode, + overrides?.planBrief, + hideUserMessage, ); }; @@ -177,6 +200,11 @@ export function useChatSend({ return true; } + // Silent plan execute requires an existing thread (no empty-bubble draft). + if (hideUserMessage || planBrief) { + return false; + } + const userMsg = buildUserMessage(trimmed, attachments, composerContext); chatStore.appendUserMessage(EMPTY_CHAT_SESSION_KEY, userMsg); @@ -215,6 +243,7 @@ export function useChatSend({ targetAgents, composerContext?.reasoningMode ?? reasoningMode, composerContext?.reasoningEffort ?? reasoningEffort, + composerContext?.conversationMode ?? conversationMode, ); navigate(`/chat/${agent}/${tid}`, { replace: true }); }); @@ -236,6 +265,7 @@ export function useChatSend({ selectedTargetAgents, reasoningMode, reasoningEffort, + conversationMode, defaultModel, t, ], diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index fbee9043c..7ce0e8b9f 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -79,6 +79,12 @@ import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import ChatComposerChrome from "./components/ChatComposerChrome"; import AskQuestionCard from "./components/AskQuestionCard"; +import PlanReadyCard from "./components/PlanReadyCard"; +import { + buildPlanBriefFromMessages, + planContinueHandoff, + planExecuteHandoff, +} from "./utils/planArtifact"; import { extractAskQuestions, isAskHitl } from "../../api/types/hitl"; import { isAgentChatReady } from "../../utils/agentError"; import { useMemoryMaintenance } from "./hooks/useMemoryMaintenance"; @@ -439,6 +445,8 @@ function ChatPageInner() { reasoningMode, reasoningEffort, handleReasoningChange, + conversationMode, + handleConversationModeChange, handleConnectorsChange, handleSkillsChange, handleKnowledgeBaseIdsChange, @@ -449,6 +457,7 @@ function ChatPageInner() { composerSession?.modelRef, composerSession?.reasoningMode, composerSession?.reasoningEffort, + activeAgent?.default_conversation_mode, ); const { contextMaxTokens, contextUsedTokens } = useChatContextWindow( @@ -548,6 +557,7 @@ function ChatPageInner() { selectedTargetAgents, reasoningMode, reasoningEffort, + conversationMode, defaultModel: activeAgent?.default_model ?? null, sendMessage, createSession, @@ -587,6 +597,52 @@ function ChatPageInner() { [interceptUserMessage, handleSend], ); + const [planReadyBrief, setPlanReadyBrief] = useState(null); + const wasStreamingRef = useRef(false); + const planModeTurnRef = useRef(false); + + useEffect(() => { + if (conversationMode === "plan" && isStreaming) { + planModeTurnRef.current = true; + } + }, [conversationMode, isStreaming]); + + useEffect(() => { + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = isStreaming; + if (!wasStreaming || isStreaming) return; + if (!planModeTurnRef.current) return; + planModeTurnRef.current = false; + if (conversationMode !== "plan") return; + const brief = buildPlanBriefFromMessages(messages); + if (brief) setPlanReadyBrief(brief); + }, [isStreaming, conversationMode, messages]); + + useEffect(() => { + setPlanReadyBrief(null); + }, [activeThreadId, resolvedAgentId]); + + const handlePlanExecute = useCallback(() => { + if (!planReadyBrief) return; + const brief = planReadyBrief; + setPlanReadyBrief(null); + const handoff = planExecuteHandoff(brief); + handleConversationModeChange(handoff.conversationMode); + wrappedHandleSend(handoff.text, undefined, { + conversationMode: handoff.conversationMode, + planBrief: handoff.planBrief, + hideUserMessage: handoff.hideUserMessage, + composerContext: { + conversationMode: handoff.conversationMode, + }, + }); + }, [planReadyBrief, handleConversationModeChange, wrappedHandleSend]); + + const handlePlanContinue = useCallback(() => { + setPlanReadyBrief(null); + handleConversationModeChange(planContinueHandoff().conversationMode); + }, [handleConversationModeChange]); + const flushQueuedItem = useCallback( (item: QueuedChatItem, ctx: ChatQueueFlushContext): boolean => { if (!ctx.threadId) { @@ -1345,6 +1401,13 @@ function ChatPageInner() {
) : null} + {planReadyBrief && !pendingAsk && !isStreaming ? ( + + ) : null} { + it("includes conversationMode in composer context (M15 payload seed)", () => { + const ctx = buildComposerContext({ + conversationMode: "ask", + }); + expect(ctx).toEqual({ conversationMode: "ask" }); + }); + + it("omits conversationMode when unset", () => { + const ctx = buildComposerContext({ skills: ["a"] }); + expect(ctx?.conversationMode).toBeUndefined(); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/chatMessages.ts b/dashboard/src/pages/Chat/utils/chatMessages.ts index 552362cbd..1c8fa9079 100644 --- a/dashboard/src/pages/Chat/utils/chatMessages.ts +++ b/dashboard/src/pages/Chat/utils/chatMessages.ts @@ -88,6 +88,7 @@ export function buildComposerContext(params: { selectedModel?: string | null; reasoningMode?: "auto" | "enabled" | "disabled"; reasoningEffort?: string | null; + conversationMode?: "ask" | "plan" | "craft"; }): UserComposerContext | undefined { const ctx: UserComposerContext = {}; let has = false; @@ -122,6 +123,10 @@ export function buildComposerContext(params: { ctx.reasoningEffort = params.reasoningEffort; has = true; } + if (params.conversationMode) { + ctx.conversationMode = params.conversationMode; + has = true; + } return has ? ctx : undefined; } diff --git a/dashboard/src/pages/Chat/utils/conversationMode.test.ts b/dashboard/src/pages/Chat/utils/conversationMode.test.ts new file mode 100644 index 000000000..f3619e02f --- /dev/null +++ b/dashboard/src/pages/Chat/utils/conversationMode.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_CONVERSATION_MODE, + parseConversationMode, +} from "./conversationMode"; + +describe("parseConversationMode", () => { + it("accepts ask / plan / craft", () => { + expect(parseConversationMode("ask")).toBe("ask"); + expect(parseConversationMode("plan")).toBe("plan"); + expect(parseConversationMode("craft")).toBe("craft"); + }); + + it("defaults unknown / null to craft", () => { + expect(parseConversationMode(null)).toBe(DEFAULT_CONVERSATION_MODE); + expect(parseConversationMode(undefined)).toBe("craft"); + expect(parseConversationMode("agent")).toBe("craft"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/conversationMode.ts b/dashboard/src/pages/Chat/utils/conversationMode.ts new file mode 100644 index 000000000..c970fb65e --- /dev/null +++ b/dashboard/src/pages/Chat/utils/conversationMode.ts @@ -0,0 +1,24 @@ +/** Conversation permission modes (#616): Ask / Plan / Craft. */ + +export type ConversationMode = "ask" | "plan" | "craft"; + +export const DEFAULT_CONVERSATION_MODE: ConversationMode = "craft"; + +const VALID: ReadonlySet = new Set(["ask", "plan", "craft"]); + +/** Composer options — default-first (WorkBuddy-style menu). */ +export const COMPOSER_CONVERSATION_MODES: readonly ConversationMode[] = [ + "craft", + "plan", + "ask", +] as const; + +export function parseConversationMode( + value: unknown, + fallback: ConversationMode = DEFAULT_CONVERSATION_MODE, +): ConversationMode { + if (typeof value === "string" && VALID.has(value)) { + return value as ConversationMode; + } + return fallback; +} diff --git a/dashboard/src/pages/Chat/utils/planArtifact.test.ts b/dashboard/src/pages/Chat/utils/planArtifact.test.ts new file mode 100644 index 000000000..2db8dee37 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/planArtifact.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage } from "../hooks/useChat"; +import { + buildPlanBriefFromMessages, + buildPlanExecuteMessage, + formatPlanBrief, + planContinueHandoff, + planExecuteHandoff, + stripConversationModeUiInstructions, +} from "./planArtifact"; + +describe("planArtifact (#616 P5/P7)", () => { + it("formats brief with summary and todos", () => { + const brief = formatPlanBrief({ + summary: "Ship modes.", + todos: [{ id: "1", content: "Ask overlay", status: "completed" }], + }); + expect(brief).toContain("Approved plan"); + expect(brief).toContain("Ship modes."); + expect(brief).toContain("1. Ask overlay (completed)"); + expect(brief).toContain("Execute this plan now."); + }); + + it("buildPlanExecuteMessage preserves brief for craft payload", () => { + const brief = formatPlanBrief({ summary: "Do it." }); + expect(buildPlanExecuteMessage(brief)).toContain("Do it."); + expect(buildPlanExecuteMessage("")).toMatch(/plan/i); + }); + + it("planExecuteHandoff switches to craft with silent planBrief metadata", () => { + const handoff = planExecuteHandoff(formatPlanBrief({ summary: "Go." })); + expect(handoff.conversationMode).toBe("craft"); + expect(handoff.hideUserMessage).toBe(true); + expect(handoff.text).toBe("Execute the approved plan now."); + expect(handoff.planBrief).toContain("Go."); + expect(handoff.planBrief).toContain("Approved plan"); + }); + + it("planContinueHandoff keeps plan mode (P8)", () => { + expect(planContinueHandoff()).toEqual({ conversationMode: "plan" }); + }); + + it("strips mode-switch UI instructions from assistant copy", () => { + const raw = [ + "当前仍在计划模式,edit_file 等修改类工具不可用。", + "", + "需要你手动切换到默认(Craft / 做一做)模式后,我才能直接修 bug 和跑测试。", + "", + "切换入口:输入框旁边的模式选择按钮,从「计划」切回默认即可。", + "", + "脚本将用 awk 采集 CPU。", + ].join("\n"); + const cleaned = stripConversationModeUiInstructions(raw); + expect(cleaned).toContain("awk"); + expect(cleaned).not.toMatch(/手动切换/); + expect(cleaned).not.toMatch(/模式选择按钮/); + expect( + stripConversationModeUiInstructions( + [ + "当前仍在计划模式,edit_file 等修改类工具不可用。", + "", + "需要你手动切换到默认(Craft / 做一做)模式。", + "", + "切换入口:输入框旁边的模式选择按钮。", + ].join("\n"), + ), + ).toBe(""); + }); + + it("buildPlanBriefFromMessages merges assistant text and write_todos", () => { + const messages: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "plan", + status: "done", + timestamp: 1, + }, + { + id: "2", + role: "assistant", + content: "", + toolData: { + name: "write_todos", + arguments: JSON.stringify({ + todos: [{ id: "1", content: "Write tests", status: "pending" }], + }), + }, + status: "done", + timestamp: 2, + }, + { + id: "3", + role: "assistant", + content: "Here is the plan.", + status: "done", + timestamp: 3, + }, + ]; + const brief = buildPlanBriefFromMessages(messages); + expect(brief).toContain("Here is the plan."); + expect(brief).toContain("Write tests"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/planArtifact.ts b/dashboard/src/pages/Chat/utils/planArtifact.ts new file mode 100644 index 000000000..f91a4300d --- /dev/null +++ b/dashboard/src/pages/Chat/utils/planArtifact.ts @@ -0,0 +1,121 @@ +import type { TodoListItem } from "../../../utils/parseWriteTodos"; +import { + collectWriteTodosFromMessages, + type WriteTodosMessageSource, +} from "../../../utils/parseWriteTodos"; +import type { ChatMessage } from "../hooks/useChat"; +import { deriveMessageContent } from "./messageContent"; + +/** Lines that tell the user how to operate Plan/Craft UI — never show in chat. */ +const MODE_UI_INSTRUCTION_RE = + /手动切换|模式选择按钮|输入框旁边|切换入口|切换到\s*默认|切换到\s*做一做|切回默认|请.*切换.*模式|switch to (?:default|craft)|mode (?:selection )?button|chat input|做一做[))]?\s*模式|Craft\s*[//]\s*做一做|修改类工具不可用|仍在计划模式/i; + +/** + * Remove assistant copy that instructs the user to flip the composer mode + * control. Product UI (PlanReadyCard) owns that handoff. + */ +export function stripConversationModeUiInstructions(text: string): string { + const raw = text.replace(/\r\n/g, "\n").trim(); + if (!raw) return ""; + + const blocks = raw.split(/\n{2,}/); + const kept = blocks.filter((block) => { + const compact = block.replace(/\s+/g, ""); + if (!compact) return false; + if (MODE_UI_INSTRUCTION_RE.test(block)) return false; + // Whole block is only a mode-name reminder. + if ( + /^(当前)?(仍?在)?计划|仅问答|Ask|Plan|Craft/.test(compact) && + compact.length < 40 + ) { + return !/模式|mode|不可用|unavailable/i.test(block); + } + return true; + }); + + // Also drop leftover single lines that match. + const lines = kept.join("\n\n").split("\n"); + const cleaned = lines + .filter((line) => !MODE_UI_INSTRUCTION_RE.test(line)) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return cleaned; +} + +/** Build the Craft-turn user message that carries an approved plan brief. */ +export function buildPlanExecuteMessage(brief: string): string { + const cleaned = brief.trim(); + if (!cleaned) { + return "Execute the plan we just prepared."; + } + return cleaned.endsWith("\n") ? cleaned : `${cleaned}\n`; +} + +export function formatPlanBrief(params: { + summary?: string; + todos?: TodoListItem[]; +}): string { + const lines: string[] = ["## Approved plan", ""]; + const summary = stripConversationModeUiInstructions( + params.summary || "", + ).trim(); + if (summary) { + lines.push(summary, ""); + } + const todos = params.todos || []; + if (todos.length > 0) { + lines.push("### Steps"); + todos.forEach((todo, index) => { + const status = todo.status ? ` (${todo.status})` : ""; + lines.push(`${index + 1}. ${todo.content}${status}`); + }); + lines.push(""); + } + lines.push("Execute this plan now."); + return `${lines.join("\n").trim()}\n`; +} + +/** Collect plan brief from the latest assistant turn (text + write_todos). */ +export function buildPlanBriefFromMessages( + messages: readonly ChatMessage[], +): string | null { + const sources = messages as unknown as WriteTodosMessageSource[]; + const todos = collectWriteTodosFromMessages(sources); + let summary = ""; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i]; + if (!msg || msg.role !== "assistant" || msg.toolData) continue; + summary = stripConversationModeUiInstructions( + deriveMessageContent(msg).textContent, + ).trim(); + if (summary) break; + } + if (!summary && todos.length === 0) return null; + return formatPlanBrief({ summary, todos }); +} + +/** Minimal user-turn text for silent Plan→Craft (matched on history reload). */ +export const PLAN_EXECUTE_USER_TRIGGER = "Execute the approved plan now."; + +/** P7: Execute plan → Craft mode; brief goes via metadata (not chat text). */ +export function planExecuteHandoff(brief: string): { + conversationMode: "craft"; + /** Silent send — no visible user bubble; planBrief drives the turn. */ + hideUserMessage: true; + /** Non-empty WS text so servers that only check text still accept the turn. */ + text: string; + planBrief: string; +} { + return { + conversationMode: "craft", + hideUserMessage: true, + text: PLAN_EXECUTE_USER_TRIGGER, + planBrief: brief.trim(), + }; +} + +/** P8: Keep planning → stay in Plan mode. */ +export function planContinueHandoff(): { conversationMode: "plan" } { + return { conversationMode: "plan" }; +} diff --git a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx index ca209f3af..491d95e40 100644 --- a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx +++ b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx @@ -157,6 +157,7 @@ export default function CreateFromExpertDrawer({ agent_id?: string; welcome_message?: string; default_model: string; + default_conversation_mode: "ask" | "plan" | "craft"; backend_choice: string; composite_default: string; root_dir?: string; @@ -217,6 +218,7 @@ export default function CreateFromExpertDrawer({ agent_id: undefined, welcome_message: defaults.welcome_message, default_model: MODEL_AUTO_VALUE, + default_conversation_mode: "craft", backend_choice: DEFAULT_BACKEND, composite_default: DEFAULT_BACKEND, skill_package_ids: [], @@ -336,6 +338,7 @@ export default function CreateFromExpertDrawer({ ...(welcomeText ? { welcome_message: welcomeText } : {}), ...buildAgentRuntimeRequest(values), enable_trajectory: values.enable_trajectory === true, + default_conversation_mode: values.default_conversation_mode, }; let body: { agent_id: string; name: string }; @@ -566,6 +569,33 @@ export default function CreateFromExpertDrawer({ /> + + + ` | owner | Primary dashboard turn endpoint. Send `{"type":"user_turn", ...}` frames; server replies with harness stream chunks ending in `{"type":"done"}` or `{"type":"error","message":"..."}`. `{"type":"ping"}` → `{"type":"pong"}`. `{"type":"subscribe","thread_id"}` → `{"type":"turn_status","thread_id","active"}` (attach to an in-flight turn without cancelling on disconnect). `{"type":"cancel","thread_id"}` stops the active turn (explicit stop; disconnect alone does **not** cancel). | +| `WS /agents/{id}/chat/ws?token=` | owner | Primary dashboard turn endpoint. Send `{"type":"user_turn", …, "conversation_mode"?: "ask"|"plan"|"craft", "plan_brief"?: string}` frames; server replies with harness stream chunks ending in `{"type":"done"}` or `{"type":"error","message":"..."}`. Omit `conversation_mode` → craft. `plan_brief` is injected into the turn system prompt (Craft handoff) and is not required in chat text. `{"type":"ping"}` → `{"type":"pong"}`. `{"type":"subscribe","thread_id"}` → `{"type":"turn_status","thread_id","active"}` (attach to an in-flight turn without cancelling on disconnect). `{"type":"cancel","thread_id"}` stops the active turn (explicit stop; disconnect alone does **not** cancel). | | `POST /agents/{id}/chat/polish` | owner | body `{text, default_model?}` → `{text}` (one-shot prompt refinement) | | `POST /agents/{id}/chat/hitl/resume` | owner | body `{thread_id, decisions: [...]}` → SSE chunk stream; finishes with `{"type":"done"}` | diff --git a/src/octop/api/routers/agents.py b/src/octop/api/routers/agents.py index c239e4642..da60f561b 100644 --- a/src/octop/api/routers/agents.py +++ b/src/octop/api/routers/agents.py @@ -21,6 +21,10 @@ read_workspace_avatar, write_workspace_avatar, ) +from octop.infra.agents.conversation_mode import ( + default_conversation_mode_from_config, + normalize_config_default_conversation_mode, +) from octop.infra.agents.profile import ( parse_config_json, parse_skill_package_ids_json, @@ -39,6 +43,16 @@ router = APIRouter() +def _validated_agent_config(config: dict[str, Any] | None) -> dict[str, Any] | None: + """Normalize conversation-mode fields; raise OctopError on invalid values.""" + if config is None: + return None + try: + return normalize_config_default_conversation_mode(config) + except ValueError as exc: + raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc + + class AgentCreateBody(AgentRuntimeFields): name: str description: str | None = None @@ -145,6 +159,7 @@ def _row_dict( "description": row.description, "persona_mbti": row.persona_mbti, "default_model": row.default_model, + "default_conversation_mode": default_conversation_mode_from_config(cfg), "system_prompt": row.system_prompt, "state": row.last_state or "unknown", "last_error": row.last_error, @@ -251,12 +266,14 @@ async def create_agent( from octop.infra.agents.manager import AgentCreateSpec # noqa: PLC0415 assert server.app_runtime is not None + config = body.config if isinstance(body.config, dict) else {} if isinstance(body.config, dict): assert_user_backend_root_dirs( user, body.config.get("backend"), policy_repo=server.services.user_policy_repo, ) + config = _validated_agent_config(body.config) or {} spec = AgentCreateSpec( name=body.name, user_id=user.id, @@ -264,7 +281,7 @@ async def create_agent( persona_mbti=body.persona_mbti, default_model=body.default_model, system_prompt=body.system_prompt, - config=body.config, + config=config, runtime_config=runtime_field_updates(body, exclude_unset=True), icon=body.icon, template_name=body.template_name, @@ -353,7 +370,8 @@ async def patch_agent( } } if body.config is not None: - updates["config_json"] = json.dumps(body.config) + validated = _validated_agent_config(body.config if isinstance(body.config, dict) else {}) + updates["config_json"] = json.dumps(validated) if body.welcome_message is not None: updates["welcome_message"] = body.welcome_message if updates: diff --git a/src/octop/api/routers/chat/models.py b/src/octop/api/routers/chat/models.py index 54f6a5804..06a93ef39 100644 --- a/src/octop/api/routers/chat/models.py +++ b/src/octop/api/routers/chat/models.py @@ -46,6 +46,20 @@ class ChatTurnBody(BaseModel): ) reasoning_mode: Literal["auto", "enabled", "disabled"] | None = None reasoning_effort: str | None = None + conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description=( + "Turn permission mode: ask (read-only), plan (plan then confirm), " + "craft (full tools). Omit to default to craft." + ), + ) + plan_brief: str | None = Field( + default=None, + description=( + "Optional approved plan text for a Craft handoff turn. " + "Injected into the system prompt by the gateway — not required in chat text." + ), + ) target_agent_ids: list[str] | None = Field( default=None, description="Optional agent ids to involve via @mention (same user only).", @@ -87,6 +101,14 @@ def from_ws_payload(cls, payload: dict[str, Any]) -> ChatTurnBody: if isinstance(payload.get("reasoning_effort"), str) and str(payload["reasoning_effort"]).strip() else None, + conversation_mode=payload.get("conversation_mode") + if payload.get("conversation_mode") in ("ask", "plan", "craft") + else None, + plan_brief=( + str(payload["plan_brief"]).strip() + if isinstance(payload.get("plan_brief"), str) and str(payload["plan_brief"]).strip() + else None + ), target_agent_ids=( [str(x) for x in payload["target_agent_ids"]] if isinstance(payload.get("target_agent_ids"), list) @@ -107,6 +129,8 @@ class UserTurnWsFrame(BaseModel): default_model: str | None = None reasoning_mode: Literal["auto", "enabled", "disabled"] | None = None reasoning_effort: str | None = None + conversation_mode: Literal["ask", "plan", "craft"] | None = None + plan_brief: str | None = None mcp_servers: list[str] | None = None knowledge_base_ids: list[str] | None = None skills: list[str] | None = None diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index c70ebac7a..c12a6b53f 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -20,6 +20,7 @@ from octop.infra.gateway.process.message_keys import ( COMPOSER_CTX_KEY, INBOUND_ATTACHMENTS_KEY, + UI_HIDDEN_KEY, ) from octop.infra.utils.llm_text import strip_thinking as _strip_thinking from octop.infra.utils.locale import normalize_locale @@ -978,6 +979,8 @@ def _serialize_history_message(msg: Any, *, user: Any = None) -> dict[str, Any] entry["composer_context"] = raw_ctx if has_user_attachments: entry["inbound_attachments"] = raw_att + if additional_kwargs.get(UI_HIDDEN_KEY): + entry["ui_hidden"] = True ts_ms = _extract_message_timestamp_ms(msg) if ts_ms is not None: entry["timestamp"] = ts_ms diff --git a/src/octop/api/routers/chat/turn.py b/src/octop/api/routers/chat/turn.py index f8b0d14c6..a46f908dc 100644 --- a/src/octop/api/routers/chat/turn.py +++ b/src/octop/api/routers/chat/turn.py @@ -124,9 +124,11 @@ def _message_content_nonempty(content: Any) -> bool: def turn_has_content(turn: ChatTurnBody) -> bool: - """True when the turn has user text or structured message blocks.""" + """True when the turn has user text, attachments, or a plan_brief handoff.""" if _turn_plain_text(turn): return True + if isinstance(turn.plan_brief, str) and turn.plan_brief.strip(): + return True for msg in reversed(turn.messages): if str(msg.get("role") or "").lower() != "user": continue @@ -172,54 +174,58 @@ def content_parts_from_dashboard_turn(turn: ChatTurnBody) -> list[ContentPart]: user_content = msg.get("content") break - if not isinstance(user_content, list): - return parts - - seen_text = bool(text) - for block in user_content: - if not isinstance(block, dict): - continue - btype = str(block.get("type") or "") - if btype == "text": - t = str(block.get("text") or "").strip() - if t and not seen_text: - parts.append(TextContent(text=t)) - seen_text = True - continue - - if btype not in ("image", "file", "image_url"): - continue - - path = _workspace_path_from_block(block) - if not path: - # Upload always returns workspace_path; ignore preview-only blocks. - continue - - media_type = _mime_from_block(block) - filename = str( - block.get("filename") or block.get("name") or Path(path).name or "attachment" - ) - use_vision = is_vision_attachment( - kind="image" if btype in ("image", "image_url") else btype, - media_type=media_type, - path=path, - ) - if use_vision: - parts.append( - ImageContent( - local_path=path, - mime_type=media_type if media_type.startswith("image/") else "image/png", - alt_text=filename, - ) + if isinstance(user_content, list): + seen_text = bool(text) + for block in user_content: + if not isinstance(block, dict): + continue + btype = str(block.get("type") or "") + if btype == "text": + t = str(block.get("text") or "").strip() + if t and not seen_text: + parts.append(TextContent(text=t)) + seen_text = True + continue + + if btype not in ("image", "file", "image_url"): + continue + + path = _workspace_path_from_block(block) + if not path: + # Upload always returns workspace_path; ignore preview-only blocks. + continue + + media_type = _mime_from_block(block) + filename = str( + block.get("filename") or block.get("name") or Path(path).name or "attachment" ) - else: - parts.append( - FileContent( - local_path=path, - filename=filename, - mime_type=media_type or "application/octet-stream", - ) + use_vision = is_vision_attachment( + kind="image" if btype in ("image", "image_url") else btype, + media_type=media_type, + path=path, ) + if use_vision: + parts.append( + ImageContent( + local_path=path, + mime_type=media_type if media_type.startswith("image/") else "image/png", + alt_text=filename, + ) + ) + else: + parts.append( + FileContent( + local_path=path, + filename=filename, + mime_type=media_type or "application/octet-stream", + ) + ) + + # Silent Plan→Craft: plan lives in metadata / system hint; still need a HumanMessage. + if not parts and isinstance(turn.plan_brief, str) and turn.plan_brief.strip(): + from octop.infra.agents.plan_artifact import PLAN_EXECUTE_USER_TRIGGER # noqa: PLC0415 + + parts.append(TextContent(text=PLAN_EXECUTE_USER_TRIGGER)) return parts @@ -332,6 +338,10 @@ def build_dashboard_inbound( metadata["reasoning_mode"] = turn.reasoning_mode if turn.reasoning_effort: metadata["reasoning_effort"] = turn.reasoning_effort + if turn.conversation_mode is not None: + metadata["conversation_mode"] = turn.conversation_mode + if turn.plan_brief: + metadata["plan_brief"] = turn.plan_brief if prepared.composer_context: metadata[COMPOSER_CTX_KEY] = prepared.composer_context if prepared.inbound_attachments: diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py index 1c01502f5..b81f5e5d8 100644 --- a/src/octop/api/routers/experts.py +++ b/src/octop/api/routers/experts.py @@ -12,7 +12,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Response from pydantic import BaseModel, Field @@ -25,6 +25,7 @@ display_published_expert_icon_url, read_snapshot_avatar, ) +from octop.infra.agents.conversation_mode import apply_default_conversation_mode from octop.infra.agents.experts.catalog import ( MANIFEST_FILENAME, build_create_spec_from_expert, @@ -95,6 +96,13 @@ class FromExpertBody(AgentRuntimeFields): ) welcome_message: str | None = None enable_trajectory: bool = True + default_conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description=( + "Initial chat permission mode for new threads " + "(ask / plan / craft). Omit to default to craft." + ), + ) class PublishExpertBody(BaseModel): @@ -129,6 +137,13 @@ class InstallPublishedExpertBody(AgentRuntimeFields): ) welcome_message: str | None = None enable_trajectory: bool = True + default_conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description=( + "Initial chat permission mode for new threads " + "(ask / plan / craft). Omit to default to craft." + ), + ) class LocalizedTextResponse(BaseModel): @@ -504,6 +519,7 @@ async def install_published_expert( welcome_message=body.welcome_message, runtime_config=runtime_field_updates(body, exclude_unset=True), enable_trajectory=body.enable_trajectory, + default_conversation_mode=body.default_conversation_mode, ), ) @@ -599,6 +615,7 @@ async def install_expert_hub_item( welcome_message=body.welcome_message, skill_package_ids=package_ids, enable_trajectory=body.enable_trajectory, + default_conversation_mode=body.default_conversation_mode, **runtime_field_updates(body, exclude_unset=False), ), ) @@ -673,6 +690,7 @@ async def create_agent_from_expert( if body.backend: config_extra["backend"] = body.backend apply_enable_trajectory(config_extra, body.enable_trajectory) + apply_default_conversation_mode(config_extra, body.default_conversation_mode) locale = resolve_user_locale( user_repo=server.services.user_repo, diff --git a/src/octop/i18n/domains/conversation.py b/src/octop/i18n/domains/conversation.py new file mode 100644 index 000000000..96916eb3c --- /dev/null +++ b/src/octop/i18n/domains/conversation.py @@ -0,0 +1,47 @@ +"""``conversation.*`` — Ask / Plan / Craft mode system hints (#616).""" + +from __future__ import annotations + +from octop.i18n.loader import tr +from octop.infra.agents.conversation_mode import ConversationMode +from octop.infra.utils.locale import Locale + +__all__ = [ + "conversation_mode_plan_brief_block", + "conversation_mode_system_hint", + "conversation_mode_tool_blocked", +] + + +def conversation_mode_system_hint( + mode: ConversationMode, + locale: str | Locale = "en", +) -> str: + """Localized system hint for the turn's conversation mode.""" + return tr(f"conversation.mode.{mode}_system_hint", locale) + + +def conversation_mode_plan_brief_block( + brief: str, + locale: str | Locale = "en", +) -> str: + """Wrap an approved plan brief for system-prompt injection (Craft handoff).""" + cleaned = brief.strip() + if not cleaned: + return "" + prefix = tr("conversation.mode.plan_brief_prefix", locale) + return f"{prefix}\n\n{cleaned}" + + +def conversation_mode_tool_blocked( + tool_name: str, + mode: ConversationMode, + locale: str | Locale = "en", +) -> str: + """Error text when a denylisted tool is invoked under Ask/Plan.""" + return tr( + "conversation.mode.tool_blocked", + locale, + tool_name=tool_name, + mode=mode, + ) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index d9f1b5b0a..582f7e92a 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -682,5 +682,14 @@ "row_total": "Total", "axis_tokens": "Tokens", "filename_prefix": "token-usage" + }, + "conversation": { + "mode": { + "ask_system_hint": "You are in Ask mode (仅问答): answer with read-only tools (knowledge search, file read, web fetch). Do not create, edit, delete, or execute anything. Do not send files, run browser/desktop/mobile actions, or delegate via task / ACP / ask_agent. If the user needs changes, say they can use Plan or Default mode — do not tell them how to operate the UI.", + "plan_system_hint": "You are in Plan mode (计划): explore with read-only tools and produce a clear step-by-step plan/spec only. Prefer write_todos and structured plan text. Do not execute, edit files, or delegate via task / ACP / ask_agent. When the plan is ready, stop — the product UI will offer confirmation to execute. Do not ask the user to change mode controls or paste internal prompts.", + "craft_system_hint": "You are in Default / Craft mode (默认): you may use the full set of allowed tools to carry out the user's request, subject to existing safety and HITL rules.", + "plan_brief_prefix": "The user confirmed the following approved plan. Execute it now in Default mode. Do not repeat the plan verbatim unless needed for clarification:", + "tool_blocked": "Tool `{tool_name}` is not available in {mode} mode. Switch to Default (Craft) to run mutating or delegated tools." + } } } diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index ba28405f8..1b254bc1f 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -682,5 +682,14 @@ "row_total": "合计", "axis_tokens": "Token 数", "filename_prefix": "Token用量" + }, + "conversation": { + "mode": { + "ask_system_hint": "当前为仅问答(Ask)模式:只用只读能力(知识库检索、读文件、网页抓取等)回答与解释。不要创建、编辑、删除或执行任何内容,不要发送文件或操作浏览器/桌面/手机,也不要通过 task / ACP / ask_agent 委派执行。若用户需要实际改动,可说明可使用计划或默认模式——不要指导用户如何操作界面控件。", + "plan_system_hint": "当前为计划(Plan)模式:用只读工具探索,只产出清晰的分步计划/规格说明。优先用 write_todos 与结构化计划文本。不要执行、改文件,也不要通过 task / ACP / ask_agent 委派。计划就绪后停止即可,产品界面会提供确认执行入口。不要让用户去切换模式按钮,也不要输出内部切换提示词。", + "craft_system_hint": "当前为默认(Craft)模式:在现有安全与 HITL 规则下,可使用已启用的完整工具集执行用户请求。", + "plan_brief_prefix": "用户已确认以下计划。请在默认模式下立即执行。除非澄清需要,否则不要原样复述整份计划:", + "tool_blocked": "当前为 {mode} 模式,工具 `{tool_name}` 不可用。如需执行改动或委派类操作,请切换到默认(Craft)模式。" + } } } diff --git a/src/octop/infra/agents/conversation_mode.py b/src/octop/infra/agents/conversation_mode.py new file mode 100644 index 000000000..6bc5c0f3f --- /dev/null +++ b/src/octop/infra/agents/conversation_mode.py @@ -0,0 +1,113 @@ +"""Conversation modes (Ask / Plan / Craft) — turn-scoped tool overlays (#616).""" + +from __future__ import annotations + +from typing import Literal + +from octop.infra.agents.tool_catalog import CRITICAL_TOOLS + +ConversationMode = Literal["ask", "plan", "craft"] + +DEFAULT_CONVERSATION_MODE: ConversationMode = "craft" + +_VALID_MODES: frozenset[str] = frozenset({"ask", "plan", "craft"}) + +# Mutating / side-effect tools blocked in Ask (and Plan). Must not intersect CRITICAL_TOOLS. +_ASK_TOOLS_DISABLED: frozenset[str] = frozenset( + { + "write_file", + "edit_file", + "execute", + "write_env_file", + "send_file_to_user", + "browser_use", + "desktop_screenshot", + "generate_image", + "generate_video", + "mobile_screenshot", + "mobile_tap", + "mobile_swipe", + "mobile_launch_app", + "mobile_ui_dump", + "mobile_handoff_to_user", + # cron mutations + "cronjob_create", + "cronjob_update", + "cronjob_delete", + "cronjob_run_now", + } +) + +# Delegation / ACP / team tools that would escape Ask/Plan into a full-autonomy child. +# ``task`` is CRITICAL for agent settings (cannot be permanently disabled) but MUST be +# turn-blocked in Ask/Plan — same idea as Cursor Plan: explore + write the plan only. +_ORCHESTRATION_ESCAPE_TOOLS: frozenset[str] = frozenset( + { + "task", + "acp_runner", + "ask_agent", + } +) + +assert _ASK_TOOLS_DISABLED.isdisjoint(CRITICAL_TOOLS) + + +def parse_conversation_mode(value: object | None) -> ConversationMode: + """Return a valid mode. ``None`` → craft; unknown non-null → ``ValueError``.""" + if value is None: + return DEFAULT_CONVERSATION_MODE + if not isinstance(value, str) or value not in _VALID_MODES: + raise ValueError(f"invalid conversation_mode: {value!r}") + return value # type: ignore[return-value] + + +def conversation_mode_tools_disabled(mode: ConversationMode) -> frozenset[str]: + """Turn-scoped denylist. ``craft`` → empty; Ask/Plan → mutating + no orchestration escape.""" + if mode == "craft": + return frozenset() + return _ASK_TOOLS_DISABLED | _ORCHESTRATION_ESCAPE_TOOLS + + +def merge_tools_disabled( + agent_disabled: frozenset[str] | set[str], + mode: ConversationMode, +) -> frozenset[str]: + """Union agent denylist with mode overlay (Ask/Plan never re-enable tools).""" + return frozenset(agent_disabled) | conversation_mode_tools_disabled(mode) + + +DEFAULT_CONVERSATION_MODE_CONFIG_KEY = "default_conversation_mode" + + +def default_conversation_mode_from_config(cfg: object | None) -> ConversationMode: + """Read agent default mode from config; missing/null → craft.""" + if not isinstance(cfg, dict): + return DEFAULT_CONVERSATION_MODE + raw = cfg.get(DEFAULT_CONVERSATION_MODE_CONFIG_KEY) + if raw is None: + return DEFAULT_CONVERSATION_MODE + return parse_conversation_mode(raw) + + +def normalize_config_default_conversation_mode(cfg: dict[str, object]) -> dict[str, object]: + """Validate/normalize ``default_conversation_mode`` in-place copy. Raises ``ValueError``.""" + if DEFAULT_CONVERSATION_MODE_CONFIG_KEY not in cfg: + return dict(cfg) + out = dict(cfg) + raw = out.get(DEFAULT_CONVERSATION_MODE_CONFIG_KEY) + if raw is None: + out.pop(DEFAULT_CONVERSATION_MODE_CONFIG_KEY, None) + return out + out[DEFAULT_CONVERSATION_MODE_CONFIG_KEY] = parse_conversation_mode(raw) + return out + + +def apply_default_conversation_mode( + config: dict[str, object], + mode: object | None, +) -> dict[str, object]: + """Write ``default_conversation_mode`` when *mode* is set. Mutates and returns *config*.""" + if mode is None: + return config + config[DEFAULT_CONVERSATION_MODE_CONFIG_KEY] = parse_conversation_mode(mode) + return config diff --git a/src/octop/infra/agents/experts/market_creation.py b/src/octop/infra/agents/experts/market_creation.py index 57e245324..adb4b1ffd 100644 --- a/src/octop/infra/agents/experts/market_creation.py +++ b/src/octop/infra/agents/experts/market_creation.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Literal +from octop.infra.agents.conversation_mode import apply_default_conversation_mode from octop.infra.agents.experts.catalog import ( WORKSPACE_MANIFEST_PATH, build_create_spec_from_expert, @@ -56,6 +57,7 @@ class SkillHubMarketAgentCreateOptions: top_p: float | None = None max_tokens: int | None = None enable_trajectory: bool = True + default_conversation_mode: str | None = None @dataclass(frozen=True) @@ -285,6 +287,7 @@ async def create_agent_from_skillhub_skillset( if options.backend: config_extra["backend"] = options.backend apply_enable_trajectory(config_extra, options.enable_trajectory) + apply_default_conversation_mode(config_extra, options.default_conversation_mode) locale = resolve_user_locale( user_repo=server.services.user_repo, diff --git a/src/octop/infra/agents/experts/published_creation.py b/src/octop/infra/agents/experts/published_creation.py index fdd330ded..b2fd8506a 100644 --- a/src/octop/infra/agents/experts/published_creation.py +++ b/src/octop/infra/agents/experts/published_creation.py @@ -13,6 +13,7 @@ from psycopg import IntegrityError as PsycopgIntegrityError from octop.infra.agents.avatar import bind_workspace_avatar_icon_url +from octop.infra.agents.conversation_mode import apply_default_conversation_mode from octop.infra.agents.experts.catalog import ( MANIFEST_FILENAME, read_workspace_manifest_welcome, @@ -46,6 +47,7 @@ class PublishedExpertInstallOptions: welcome_message: str | None = None runtime_config: dict[str, Any] | None = None enable_trajectory: bool = True + default_conversation_mode: str | None = None def _snapshot_dir(services: Any, expert_id: str) -> Path: @@ -308,6 +310,7 @@ async def install_published_expert( if options.backend: config_extra["backend"] = options.backend apply_enable_trajectory(config_extra, options.enable_trajectory) + apply_default_conversation_mode(config_extra, options.default_conversation_mode) async def seed_snapshot(created_row: Any, workspace: Any) -> None: await seed_expert_directory(expert_dir=snapshot_dir, workspace=workspace) diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index a0d253583..e6eed4489 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -2548,6 +2548,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: from octop.infra.agents.middleware.binary_read_guard import BinaryReadGuardMiddleware from octop.infra.agents.middleware.browser_profile import BrowserProfileMiddleware + from octop.infra.agents.middleware.conversation_mode import ConversationModeMiddleware from octop.infra.agents.middleware.reasoning import ReasoningRequestMiddleware from octop.infra.agents.middleware.thread_artifacts import ThreadArtifactsMiddleware from octop.infra.agents.middleware.token_quota import TokenQuotaMiddleware @@ -2560,6 +2561,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: # BinaryReadGuard stays Octop-specific (inbound/attachment product policy). # ThreadArtifacts writes workspace paths onto threads after successful tools. # WorkspaceImageMaterialize expands path-only vision refs at model-call time. + # ConversationMode applies turn Ask/Plan denylist + system hint from configurable. agent_middleware: list[Any] = [ *plugin_middleware, TokenQuotaMiddleware( @@ -2568,6 +2570,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: ), ReasoningRequestMiddleware(), KnowledgeSearchHintMiddleware(), + ConversationModeMiddleware(), BrowserProfileMiddleware(), BinaryReadGuardMiddleware(), WorkspaceImageMaterializeMiddleware(workspace=ws), diff --git a/src/octop/infra/agents/middleware/conversation_mode.py b/src/octop/infra/agents/middleware/conversation_mode.py new file mode 100644 index 000000000..35f411618 --- /dev/null +++ b/src/octop/infra/agents/middleware/conversation_mode.py @@ -0,0 +1,195 @@ +"""Turn-scoped Ask / Plan / Craft: filter tools, inject system hint, block denylist calls.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.tools.base import BaseTool +from langgraph.config import get_config +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.types import Command + +from octop.infra.agents.conversation_mode import ( + DEFAULT_CONVERSATION_MODE, + ConversationMode, + conversation_mode_tools_disabled, + parse_conversation_mode, +) + +logger = logging.getLogger(__name__) + +CONFIG_MODE_KEY = "conversation_mode" +CONFIG_HINT_KEY = "conversation_mode_hint" + + +def _tool_name(tool: BaseTool | dict[str, Any]) -> str: + if isinstance(tool, dict): + fn = tool.get("function") + if isinstance(fn, dict) and fn.get("name"): + return str(fn["name"]) + if tool.get("name"): + return str(tool["name"]) + return "" + return str(getattr(tool, "name", "") or "") + + +def mode_from_configurable(configurable: dict[str, Any] | None) -> ConversationMode: + """Resolve turn mode from LangGraph configurable; unknown → craft.""" + raw = (configurable or {}).get(CONFIG_MODE_KEY) + if raw is None: + return DEFAULT_CONVERSATION_MODE + try: + return parse_conversation_mode(raw) + except ValueError: + return DEFAULT_CONVERSATION_MODE + + +def _current_mode() -> ConversationMode: + cfg = get_config().get("configurable") or {} + if not isinstance(cfg, dict): + return DEFAULT_CONVERSATION_MODE + return mode_from_configurable(cfg) + + +def _current_hint() -> str: + cfg = get_config().get("configurable") or {} + if not isinstance(cfg, dict): + return "" + raw = cfg.get(CONFIG_HINT_KEY) + return raw.strip() if isinstance(raw, str) else "" + + +def _hint_already_present(content: str | list[Any] | Any, hint: str) -> bool: + if isinstance(content, str): + return hint in content + if isinstance(content, list): + for block in content: + if isinstance(block, str) and hint in block: + return True + if isinstance(block, dict): + text = block.get("text") + if isinstance(text, str) and hint in text: + return True + return False + return hint in str(content) + + +def _append_system_hint(request: ModelRequest[Any], hint: str) -> ModelRequest[Any]: + cleaned = hint.strip() + if not cleaned: + return request + existing = request.system_message + if existing is None: + return request.override(system_message=SystemMessage(content=cleaned)) + content = existing.content + if _hint_already_present(content, cleaned): + return request + if isinstance(content, str): + merged: str | list[Any] = f"{content.rstrip()}\n\n{cleaned}" + elif isinstance(content, list): + merged = [*content, {"type": "text", "text": cleaned}] + else: + merged = f"{content}\n\n{cleaned}" + return request.override(system_message=SystemMessage(content=merged)) + + +def _filter_tools(request: ModelRequest[Any], disabled: frozenset[str]) -> ModelRequest[Any]: + if not disabled: + return request + tools_in = list(request.tools or []) + if not tools_in: + return request + filtered = [t for t in tools_in if _tool_name(t) not in disabled] + if len(filtered) == len(tools_in): + return request + return request.override(tools=filtered) + + +def apply_conversation_mode_to_request(request: ModelRequest[Any]) -> ModelRequest[Any]: + """Filter Ask/Plan denylist tools and append the stamped system hint.""" + mode = _current_mode() + out = _filter_tools(request, conversation_mode_tools_disabled(mode)) + return _append_system_hint(out, _current_hint()) + + +def _blocked_tool_message( + *, tool_name: str, mode: ConversationMode, tool_call_id: str +) -> ToolMessage: + from octop.i18n.domains.conversation import conversation_mode_tool_blocked + + cfg = get_config().get("configurable") or {} + locale = "en" + if isinstance(cfg, dict): + raw_locale = cfg.get("locale") + if isinstance(raw_locale, str) and raw_locale.strip(): + locale = raw_locale.strip() + return ToolMessage( + content=conversation_mode_tool_blocked(tool_name, mode, locale), + tool_call_id=tool_call_id, + status="error", + ) + + +class ConversationModeMiddleware(AgentMiddleware[Any, Any]): + """Apply turn ``conversation_mode`` from configurable — no agent-global mutation.""" + + def wrap_model_call( + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], ModelResponse[Any]], + ) -> ModelResponse[Any]: + return handler(apply_conversation_mode_to_request(request)) + + async def awrap_model_call( + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]], + ) -> ModelResponse[Any]: + return await handler(apply_conversation_mode_to_request(request)) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + mode = _current_mode() + disabled = conversation_mode_tools_disabled(mode) + tool_name = str(request.tool_call.get("name") or "") + if tool_name and tool_name in disabled: + logger.info("ConversationMode blocked tool %s (mode=%s)", tool_name, mode) + return _blocked_tool_message( + tool_name=tool_name, + mode=mode, + tool_call_id=str(request.tool_call.get("id") or ""), + ) + return handler(request) + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + mode = _current_mode() + disabled = conversation_mode_tools_disabled(mode) + tool_name = str(request.tool_call.get("name") or "") + if tool_name and tool_name in disabled: + logger.info("ConversationMode blocked tool %s (mode=%s)", tool_name, mode) + return _blocked_tool_message( + tool_name=tool_name, + mode=mode, + tool_call_id=str(request.tool_call.get("id") or ""), + ) + return await handler(request) + + +__all__ = [ + "CONFIG_HINT_KEY", + "CONFIG_MODE_KEY", + "ConversationModeMiddleware", + "apply_conversation_mode_to_request", + "mode_from_configurable", +] diff --git a/src/octop/infra/agents/plan_artifact.py b/src/octop/infra/agents/plan_artifact.py new file mode 100644 index 000000000..d0edfb568 --- /dev/null +++ b/src/octop/infra/agents/plan_artifact.py @@ -0,0 +1,51 @@ +"""Plan brief artifact for Plan → Craft handoff (#616 P5).""" + +from __future__ import annotations + +from typing import Any + +# Minimal HumanMessage text when the dashboard sends plan_brief with no chat bubble. +# Full plan lives in the system hint; this only gives the model a user-turn trigger. +PLAN_EXECUTE_USER_TRIGGER = "Execute the approved plan now." + + +def format_plan_brief(*, summary: str = "", todos: list[dict[str, Any]] | None = None) -> str: + """Serialize an approved plan into a Craft-turn brief (markdown).""" + lines: list[str] = ["## Approved plan", ""] + text = (summary or "").strip() + if text: + lines.extend([text, ""]) + items = todos or [] + if items: + lines.append("### Steps") + for idx, raw in enumerate(items, start=1): + if isinstance(raw, str): + content = raw.strip() + elif isinstance(raw, dict): + content = str( + raw.get("content") or raw.get("text") or raw.get("title") or "" + ).strip() + else: + content = "" + if not content: + continue + status = "" + if isinstance(raw, dict) and raw.get("status"): + status = f" ({raw['status']})" + lines.append(f"{idx}. {content}{status}") + lines.append("") + lines.append("Execute this plan now.") + return "\n".join(lines).strip() + "\n" + + +def parse_plan_brief_steps(brief: str) -> list[str]: + """Extract numbered step lines from a brief produced by :func:`format_plan_brief`.""" + steps: list[str] = [] + for line in (brief or "").splitlines(): + stripped = line.strip() + if len(stripped) >= 3 and stripped[0].isdigit() and ". " in stripped[:6]: + _, _, rest = stripped.partition(". ") + content = rest.strip() + if content: + steps.append(content) + return steps diff --git a/src/octop/infra/gateway/process/message_keys.py b/src/octop/infra/gateway/process/message_keys.py index 8a454dd4a..88d89ba38 100644 --- a/src/octop/infra/gateway/process/message_keys.py +++ b/src/octop/infra/gateway/process/message_keys.py @@ -11,6 +11,8 @@ # Persisted on HumanMessage.additional_kwargs for dashboard history UI. COMPOSER_CTX_KEY = "octop_composer_context" INBOUND_ATTACHMENTS_KEY = "octop_inbound_attachments" +# Plan→Craft silent handoff: history should not render this user turn as a bubble. +UI_HIDDEN_KEY = "octop_ui_hidden" def build_composer_context( diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index 59cc13bd3..b719e985b 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -20,6 +20,11 @@ from langchain_core.messages import AIMessage, HumanMessage from octop.i18n.domains.stream import format_stream_error +from octop.infra.agents.conversation_mode import ( + DEFAULT_CONVERSATION_MODE, + ConversationMode, + parse_conversation_mode, +) from octop.infra.agents.profile import parse_config_json from octop.infra.agents.providers.reasoning import reasoning_request_parameters from octop.infra.errors import OctopError @@ -795,6 +800,7 @@ async def __call__(self, msg: InboundMessage) -> AsyncIterator[MessageEvent]: explicit_ids=None, locale=locale, ) + self._attach_conversation_mode_config(request, msg.metadata, locale=locale) if mcp_servers: request["mcp_servers"] = mcp_servers @@ -1103,6 +1109,7 @@ async def _build_dashboard_request( from octop.infra.gateway.process.message_keys import ( # noqa: PLC0415 COMPOSER_CTX_KEY, INBOUND_ATTACHMENTS_KEY, + UI_HIDDEN_KEY, ) media_backend = media_backend_for_agent(self._agent_manager, agent_id) @@ -1139,6 +1146,10 @@ async def _build_dashboard_request( attachments = meta.get(INBOUND_ATTACHMENTS_KEY) if isinstance(attachments, list) and attachments: message_kwargs[INBOUND_ATTACHMENTS_KEY] = attachments + raw_brief = meta.get("plan_brief") + if isinstance(raw_brief, str) and raw_brief.strip(): + # Dashboard PlanReady CTA — do not render the trigger as a user bubble. + message_kwargs[UI_HIDDEN_KEY] = True explicit_mcp = meta.get("mcp_servers") # Dashboard always sends mcp_servers (possibly []); trust that list so @@ -1197,6 +1208,7 @@ async def _build_dashboard_request( else None, locale=locale, ) + self._attach_conversation_mode_config(request, meta, locale=locale) if mcp_servers: request["mcp_servers"] = mcp_servers @@ -1247,6 +1259,40 @@ def _attach_turn_knowledge_config( configurable["locale"] = locale request["configurable"] = configurable + @staticmethod + def _conversation_mode_from_meta(meta: dict[str, Any] | None) -> ConversationMode: + """Parse turn mode; unknown / missing → craft (compat default).""" + raw = (meta or {}).get("conversation_mode") + if raw not in ("ask", "plan", "craft"): + return DEFAULT_CONVERSATION_MODE + return parse_conversation_mode(raw) + + def _attach_conversation_mode_config( + self, + request: dict[str, Any], + meta: dict[str, Any] | None, + *, + locale: str | None = None, + ) -> None: + """Stamp resolved conversation_mode (+ localized system hint) onto configurable.""" + from octop.i18n.domains.conversation import ( + conversation_mode_plan_brief_block, + conversation_mode_system_hint, + ) + + mode = self._conversation_mode_from_meta(meta) + configurable = dict(request.get("configurable") or {}) + configurable["conversation_mode"] = mode + hint_locale = locale or str(configurable.get("locale") or "en") + hint = conversation_mode_system_hint(mode, hint_locale) + raw_brief = (meta or {}).get("plan_brief") + brief = raw_brief.strip() if isinstance(raw_brief, str) else "" + if brief: + configurable["plan_brief"] = brief + hint = f"{hint}\n\n{conversation_mode_plan_brief_block(brief, hint_locale)}" + configurable["conversation_mode_hint"] = hint + request["configurable"] = configurable + async def _resolve_turn_mcp_servers( self, *, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 56880a12d..e0a5b3cd2 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -242,12 +242,14 @@ def test_build_harness_config_includes_cronjob_tools_when_cron_manager_set( def test_build_harness_config_includes_search_knowledge_without_cron( manager: AgentManager, ) -> None: + from octop.infra.agents.middleware.conversation_mode import ConversationModeMiddleware from octop.infra.knowledge.hint import KnowledgeSearchHintMiddleware cfg = manager._build_harness_config(_row(agent_id="AGT001")) assert cfg.tools is not None assert {t.name for t in cfg.tools} == {"search_knowledge"} assert any(isinstance(item, KnowledgeSearchHintMiddleware) for item in (cfg.middleware or [])) + assert any(isinstance(item, ConversationModeMiddleware) for item in (cfg.middleware or [])) def test_build_harness_config_defaults_local_shell_backend(manager: AgentManager) -> None: diff --git a/tests/unit/agents/test_conversation_mode.py b/tests/unit/agents/test_conversation_mode.py new file mode 100644 index 000000000..687c95a66 --- /dev/null +++ b/tests/unit/agents/test_conversation_mode.py @@ -0,0 +1,137 @@ +"""Unit tests for conversation mode parse + turn-scoped tool overlays (Sprint 0 / #616).""" + +from __future__ import annotations + +import pytest + +from octop.infra.agents.conversation_mode import ( + DEFAULT_CONVERSATION_MODE, + conversation_mode_tools_disabled, + merge_tools_disabled, + parse_conversation_mode, +) + + +def test_parse_conversation_mode_accepts_ask_plan_craft() -> None: + assert parse_conversation_mode("ask") == "ask" + assert parse_conversation_mode("plan") == "plan" + assert parse_conversation_mode("craft") == "craft" + + +def test_parse_conversation_mode_rejects_unknown() -> None: + with pytest.raises(ValueError): + parse_conversation_mode("agent") + with pytest.raises(ValueError): + parse_conversation_mode("") + with pytest.raises(ValueError): + parse_conversation_mode(123) + + +def test_parse_conversation_mode_none_defaults_to_craft() -> None: + assert parse_conversation_mode(None) == "craft" + assert parse_conversation_mode(None) == DEFAULT_CONVERSATION_MODE + + +def test_ask_tools_disabled_includes_mutating_builtins() -> None: + disabled = conversation_mode_tools_disabled("ask") + for name in ( + "write_file", + "edit_file", + "execute", + "write_env_file", + "send_file_to_user", + "browser_use", + "desktop_screenshot", + "generate_image", + "generate_video", + "mobile_tap", + "mobile_swipe", + "mobile_launch_app", + "mobile_screenshot", + "mobile_ui_dump", + "mobile_handoff_to_user", + ): + assert name in disabled, name + + +def test_ask_tools_disabled_keeps_explore_tools_blocks_orchestration_escape() -> None: + disabled = conversation_mode_tools_disabled("ask") + for name in ("ls", "read_file", "glob", "grep", "write_todos"): + assert name not in disabled, name + assert "search_knowledge" not in disabled + assert "web_fetch" not in disabled + # CRITICAL ``task`` must still be turn-blocked so Plan/Ask cannot escape via subagents. + for name in ("task", "acp_runner", "ask_agent"): + assert name in disabled, name + + +def test_craft_tools_disabled_is_empty() -> None: + assert conversation_mode_tools_disabled("craft") == frozenset() + + +def test_merge_unions_agent_disabled_with_ask_overlay() -> None: + agent = frozenset({"web_fetch", "tavily_search"}) + merged = merge_tools_disabled(agent, "ask") + assert "web_fetch" in merged + assert "tavily_search" in merged + assert "write_file" in merged + assert "execute" in merged + assert "task" in merged + # Ask never re-enables: craft merge keeps only agent disables + assert merge_tools_disabled(agent, "craft") == agent + + +def test_plan_tools_disabled_matches_ask_overlay() -> None: + """Plan uses the same denylist as Ask (mutating + no orchestration escape).""" + ask = conversation_mode_tools_disabled("ask") + plan = conversation_mode_tools_disabled("plan") + assert plan == ask + + +def test_plan_allows_write_todos_and_forbids_mutating_and_delegation() -> None: + """Plan keeps write_todos; blocks execute/write/edit and task/ACP escape.""" + disabled = conversation_mode_tools_disabled("plan") + assert "write_todos" not in disabled + assert "execute" in disabled + assert "write_file" in disabled + assert "edit_file" in disabled + assert "task" in disabled + assert "acp_runner" in disabled + assert "ask_agent" in disabled + + +def test_default_conversation_mode_from_config() -> None: + from octop.infra.agents.conversation_mode import default_conversation_mode_from_config + + assert default_conversation_mode_from_config(None) == "craft" + assert default_conversation_mode_from_config({}) == "craft" + assert default_conversation_mode_from_config({"default_conversation_mode": "ask"}) == "ask" + with pytest.raises(ValueError): + default_conversation_mode_from_config({"default_conversation_mode": "agent"}) + + +def test_normalize_config_default_conversation_mode() -> None: + from octop.infra.agents.conversation_mode import ( + normalize_config_default_conversation_mode, + ) + + assert normalize_config_default_conversation_mode({"x": 1}) == {"x": 1} + assert normalize_config_default_conversation_mode({"default_conversation_mode": "plan"}) == { + "default_conversation_mode": "plan" + } + cleaned = normalize_config_default_conversation_mode( + {"default_conversation_mode": None, "x": 1} + ) + assert "default_conversation_mode" not in cleaned + assert cleaned["x"] == 1 + with pytest.raises(ValueError): + normalize_config_default_conversation_mode({"default_conversation_mode": "nope"}) + + +def test_apply_default_conversation_mode() -> None: + from octop.infra.agents.conversation_mode import apply_default_conversation_mode + + cfg: dict[str, object] = {"x": 1} + assert apply_default_conversation_mode(cfg, None) == {"x": 1} + apply_default_conversation_mode(cfg, "ask") + assert cfg["default_conversation_mode"] == "ask" diff --git a/tests/unit/agents/test_conversation_mode_middleware.py b/tests/unit/agents/test_conversation_mode_middleware.py new file mode 100644 index 000000000..ad0ac8173 --- /dev/null +++ b/tests/unit/agents/test_conversation_mode_middleware.py @@ -0,0 +1,128 @@ +"""Unit tests for ConversationModeMiddleware (#616).""" + +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain.agents.middleware import ModelRequest +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.tools import StructuredTool +from langgraph.config import var_child_runnable_config +from langgraph.prebuilt.tool_node import ToolCallRequest + +from octop.infra.agents.middleware.conversation_mode import ( + ConversationModeMiddleware, + apply_conversation_mode_to_request, +) + + +@contextmanager +def _configurable(**kwargs: object): + token = var_child_runnable_config.set({"configurable": kwargs}) + try: + yield + finally: + var_child_runnable_config.reset(token) + + +def _tool(name: str) -> StructuredTool: + return StructuredTool.from_function( + func=lambda: "x", + name=name, + description=name, + ) + + +def test_ask_filters_mutating_tools_and_keeps_read_tools() -> None: + request = ModelRequest( + model=MagicMock(), + messages=[], + system_message=SystemMessage(content="base"), + tools=[_tool("read_file"), _tool("write_file"), _tool("execute"), _tool("write_todos")], + ) + with _configurable( + conversation_mode="ask", + conversation_mode_hint="ASK_HINT", + ): + out = apply_conversation_mode_to_request(request) + names = [getattr(t, "name", None) for t in (out.tools or [])] + assert names == ["read_file", "write_todos"] + assert out.system_message is not None + assert "base" in str(out.system_message.content) + assert "ASK_HINT" in str(out.system_message.content) + + +def test_plan_allows_write_todos_and_blocks_execute() -> None: + request = ModelRequest( + model=MagicMock(), + messages=[], + tools=[_tool("write_todos"), _tool("execute"), _tool("edit_file")], + ) + with _configurable(conversation_mode="plan", conversation_mode_hint="PLAN_HINT"): + out = apply_conversation_mode_to_request(request) + names = [getattr(t, "name", None) for t in (out.tools or [])] + assert names == ["write_todos"] + assert "PLAN_HINT" in str(out.system_message.content) + + +def test_craft_is_passthrough_for_tools() -> None: + tools = [_tool("write_file"), _tool("execute")] + request = ModelRequest( + model=MagicMock(), + messages=[], + system_message=SystemMessage(content="base"), + tools=tools, + ) + with _configurable(conversation_mode="craft", conversation_mode_hint="CRAFT_HINT"): + out = apply_conversation_mode_to_request(request) + assert [getattr(t, "name", None) for t in (out.tools or [])] == ["write_file", "execute"] + assert "CRAFT_HINT" in str(out.system_message.content) + + +def test_hint_not_duplicated_on_repeat() -> None: + request = ModelRequest( + model=MagicMock(), + messages=[], + system_message=SystemMessage(content="base\n\nHINT"), + tools=[], + ) + with _configurable(conversation_mode="ask", conversation_mode_hint="HINT"): + out = apply_conversation_mode_to_request(request) + assert str(out.system_message.content).count("HINT") == 1 + + +@pytest.mark.asyncio +async def test_awrap_tool_call_blocks_ask_denylist() -> None: + mw = ConversationModeMiddleware() + request = ToolCallRequest( + tool_call={"name": "execute", "args": {}, "id": "c1"}, + tool=None, # type: ignore[arg-type] + state={}, # type: ignore[arg-type] + runtime=None, # type: ignore[arg-type] + ) + handler = AsyncMock(return_value=ToolMessage(content="ran", tool_call_id="c1")) + with _configurable(conversation_mode="ask", locale="en"): + result = await mw.awrap_tool_call(request, handler) + assert isinstance(result, ToolMessage) + assert result.status == "error" + assert "execute" in result.content + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_awrap_tool_call_allows_craft() -> None: + mw = ConversationModeMiddleware() + request = ToolCallRequest( + tool_call={"name": "execute", "args": {}, "id": "c1"}, + tool=None, # type: ignore[arg-type] + state={}, # type: ignore[arg-type] + runtime=None, # type: ignore[arg-type] + ) + ok = ToolMessage(content="ran", tool_call_id="c1") + handler = AsyncMock(return_value=ok) + with _configurable(conversation_mode="craft"): + result = await mw.awrap_tool_call(request, handler) + assert result is ok + handler.assert_awaited_once() diff --git a/tests/unit/agents/test_plan_artifact.py b/tests/unit/agents/test_plan_artifact.py new file mode 100644 index 000000000..f4e8ccccd --- /dev/null +++ b/tests/unit/agents/test_plan_artifact.py @@ -0,0 +1,27 @@ +"""Unit tests for Plan → Craft brief artifact (#616 P5).""" + +from __future__ import annotations + +from octop.infra.agents.plan_artifact import format_plan_brief, parse_plan_brief_steps + + +def test_format_plan_brief_round_trip_steps() -> None: + brief = format_plan_brief( + summary="Ship conversation modes.", + todos=[ + {"id": "1", "content": "Ask denylist", "status": "completed"}, + {"id": "2", "content": "Plan CTA", "status": "pending"}, + ], + ) + assert "Approved plan" in brief + assert "Ship conversation modes." in brief + assert "Execute this plan now." in brief + steps = parse_plan_brief_steps(brief) + assert steps[0].startswith("Ask denylist") + assert "Plan CTA" in steps[1] + + +def test_format_plan_brief_summary_only() -> None: + brief = format_plan_brief(summary="Just do the thing.") + assert "Just do the thing." in brief + assert parse_plan_brief_steps(brief) == [] diff --git a/tests/unit/api/test_agent_conversation_mode.py b/tests/unit/api/test_agent_conversation_mode.py new file mode 100644 index 000000000..266af857a --- /dev/null +++ b/tests/unit/api/test_agent_conversation_mode.py @@ -0,0 +1,64 @@ +"""Agent default_conversation_mode in API row payload (#616 M8).""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from octop.api.routers.agents import _row_dict, _validated_agent_config +from octop.infra.errors import ErrorCode, OctopError + + +def _agent_row(**kwargs: object) -> SimpleNamespace: + base = { + "id": 1, + "agent_id": "ag1", + "user_id": 1, + "name": "A", + "description": None, + "persona_mbti": None, + "default_model": None, + "system_prompt": None, + "last_state": "stopped", + "last_error": None, + "config_json": "{}", + "icon": None, + "template_name": None, + "icon_name": None, + "icon_url": None, + "color": None, + "skill_package_ids": None, + "published_expert_id": None, + "welcome_message": None, + "is_shared": 0, + "updated_at": None, + } + base.update(kwargs) + return SimpleNamespace(**base) + + +def test_row_dict_exposes_default_conversation_mode_from_config() -> None: + import json + + row = _agent_row(config_json=json.dumps({"default_conversation_mode": "ask"})) + payload = _row_dict(row, viewer_user_id=1) + assert payload["default_conversation_mode"] == "ask" + assert payload["config"]["default_conversation_mode"] == "ask" + + +def test_row_dict_defaults_conversation_mode_to_craft() -> None: + payload = _row_dict(_agent_row(), viewer_user_id=1) + assert payload["default_conversation_mode"] == "craft" + + +def test_validated_agent_config_rejects_invalid_mode() -> None: + with pytest.raises(OctopError) as ei: + _validated_agent_config({"default_conversation_mode": "agent"}) + assert ei.value.code == ErrorCode.INTERNAL_ERROR + assert ei.value.status == 400 + + +def test_validated_agent_config_accepts_ask() -> None: + out = _validated_agent_config({"default_conversation_mode": "ask", "foo": 1}) + assert out == {"default_conversation_mode": "ask", "foo": 1} diff --git a/tests/unit/api/test_chat_conversation_mode.py b/tests/unit/api/test_chat_conversation_mode.py new file mode 100644 index 000000000..b907d48a8 --- /dev/null +++ b/tests/unit/api/test_chat_conversation_mode.py @@ -0,0 +1,110 @@ +"""ChatTurnBody / inbound metadata for conversation_mode (#616 M1 API).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from octop.api.routers.chat.models import ChatTurnBody + + +def test_chat_turn_body_accepts_ask_plan_craft() -> None: + for mode in ("ask", "plan", "craft"): + body = ChatTurnBody(text="hi", conversation_mode=mode) # type: ignore[arg-type] + assert body.conversation_mode == mode + + +def test_chat_turn_body_rejects_unknown_mode() -> None: + with pytest.raises(ValidationError): + ChatTurnBody(text="hi", conversation_mode="agent") # type: ignore[arg-type] + + +def test_chat_turn_body_omitted_mode_is_none() -> None: + body = ChatTurnBody(text="hi") + assert body.conversation_mode is None + + +def test_from_ws_payload_parses_conversation_mode() -> None: + body = ChatTurnBody.from_ws_payload({"text": "hi", "conversation_mode": "ask"}) + assert body.conversation_mode == "ask" + + +def test_from_ws_payload_drops_unknown_conversation_mode() -> None: + body = ChatTurnBody.from_ws_payload({"text": "hi", "conversation_mode": "agent"}) + assert body.conversation_mode is None + + +def test_build_dashboard_inbound_stamps_conversation_mode_metadata() -> None: + from harness_gateway.models import TextContent + + from octop.api.routers.chat.turn import PreparedDashboardTurn, build_dashboard_inbound + + prepared = PreparedDashboardTurn( + thread_id="t1", + session_key="sk", + mcp_servers=None, + skills=None, + model_ref=None, + inbound_content=[TextContent(text="hi")], + composer_context=None, + inbound_attachments=[], + ) + inbound = build_dashboard_inbound( + agent_id="agent-1", + user_id=1, + prepared=prepared, + turn=ChatTurnBody(text="hi", conversation_mode="ask"), + ws_connection_id="conn", + ) + assert inbound.metadata["conversation_mode"] == "ask" + + +def test_build_dashboard_inbound_stamps_plan_brief_metadata() -> None: + from harness_gateway.models import TextContent + + from octop.api.routers.chat.turn import PreparedDashboardTurn, build_dashboard_inbound + + prepared = PreparedDashboardTurn( + thread_id="t1", + session_key="sk", + mcp_servers=None, + skills=None, + model_ref=None, + inbound_content=[TextContent(text="按计划执行")], + composer_context=None, + inbound_attachments=[], + ) + inbound = build_dashboard_inbound( + agent_id="agent-1", + user_id=1, + prepared=prepared, + turn=ChatTurnBody( + text="按计划执行", + conversation_mode="craft", + plan_brief="## Approved plan\n\nDo it.", + ), + ws_connection_id="conn", + ) + assert inbound.metadata["conversation_mode"] == "craft" + assert inbound.metadata["plan_brief"] == "## Approved plan\n\nDo it." + + +def test_turn_has_content_accepts_plan_brief_only() -> None: + from octop.api.routers.chat.turn import turn_has_content + + assert turn_has_content(ChatTurnBody(text="", plan_brief="## Approved plan\n\nDo it.")) + assert not turn_has_content(ChatTurnBody(text="")) + + +def test_content_parts_injects_trigger_for_plan_brief_only() -> None: + from harness_gateway.models import TextContent + + from octop.api.routers.chat.turn import content_parts_from_dashboard_turn + from octop.infra.agents.plan_artifact import PLAN_EXECUTE_USER_TRIGGER + + parts = content_parts_from_dashboard_turn( + ChatTurnBody(text="", plan_brief="## Approved plan\n\nDo it.") + ) + assert len(parts) == 1 + assert isinstance(parts[0], TextContent) + assert parts[0].text == PLAN_EXECUTE_USER_TRIGGER diff --git a/tests/unit/gateway/test_processor_conversation_mode.py b/tests/unit/gateway/test_processor_conversation_mode.py new file mode 100644 index 000000000..e30abbc3b --- /dev/null +++ b/tests/unit/gateway/test_processor_conversation_mode.py @@ -0,0 +1,177 @@ +"""GlobalProcessor turn-scoped conversation_mode configurable stamp (#616).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from harness_gateway.models import ChannelSubject, InboundMessage, TextContent + +from octop.infra.gateway.process.processor import GlobalProcessor +from octop.infra.gateway.slash.dispatcher import SlashDispatcher + + +def _processor(agent_manager: MagicMock) -> GlobalProcessor: + thread_registry = MagicMock() + thread_registry.get_or_create_by_key = AsyncMock(return_value="thr-1") + thread_registry.touch_last_active = MagicMock() + thread_registry.set_title_if_null = MagicMock() + return GlobalProcessor( + agent_manager=agent_manager, + thread_registry=thread_registry, + audit_repo=MagicMock(), + agent_repo=MagicMock(get=MagicMock(return_value=MagicMock(user_id=1, default_model=None))), + user_repo=MagicMock(get=MagicMock(return_value=None)), + connector_repo=MagicMock(), + dispatcher=SlashDispatcher(), + usage_repo=None, + gateway=None, + ) + + +def _agent_manager() -> MagicMock: + mgr = MagicMock() + mgr.merge_turn_mcp_servers = MagicMock(return_value=None) + mgr.prepare_chat_mcp = AsyncMock(return_value=[]) + mgr.get_row = MagicMock(return_value=None) + mgr.get_config = MagicMock(return_value={"tools_disabled": ["web_fetch"]}) + mgr.sync_effective_tools_disabled = MagicMock() + mgr.sync_tools_disabled = MagicMock() + mgr.providers = MagicMock() + mgr.providers.is_model_ref_usable = MagicMock(return_value=False) + mgr.providers.resolve_explicit_default_model = MagicMock(return_value=None) + mgr.providers.resolve_model_for_multimodal_turn = MagicMock(side_effect=lambda ref, **_k: ref) + mgr.providers.get_model_reasoning_capability = MagicMock(return_value=None) + mgr.get_thread_model = MagicMock(return_value=None) + + async def fake_stream(_aid: str, _req: dict): + yield {"type": "token", "content": "ok"} + + mgr.stream = fake_stream + return mgr + + +@pytest.mark.asyncio +async def test_dashboard_request_stamps_conversation_mode_ask() -> None: + processor = _processor(_agent_manager()) + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={"conversation_mode": "ask"}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta=msg.metadata or {}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "ask" + hint = (request.get("configurable") or {}).get("conversation_mode_hint") + assert isinstance(hint, str) and hint + + +@pytest.mark.asyncio +async def test_dashboard_request_stamps_plan_system_hint() -> None: + """Plan turn attaches plan-mode system hint for ConversationModeMiddleware.""" + processor = _processor(_agent_manager()) + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="plan it")], + metadata={"conversation_mode": "plan"}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta=msg.metadata or {}, + ) + cfg = request.get("configurable") or {} + assert cfg["conversation_mode"] == "plan" + hint = str(cfg.get("conversation_mode_hint") or "") + assert "plan" in hint.lower() or "想一想" in hint + assert "write_todos" in hint.lower() or "plan" in hint.lower() + + +@pytest.mark.asyncio +async def test_dashboard_request_defaults_conversation_mode_to_craft() -> None: + processor = _processor(_agent_manager()) + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta={}, + ) + assert (request.get("configurable") or {})["conversation_mode"] == "craft" + + +@pytest.mark.asyncio +async def test_dashboard_request_stamps_plan_brief_into_system_hint() -> None: + processor = _processor(_agent_manager()) + brief = "## Approved plan\n\nWrite load.sh\n\nExecute this plan now." + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="按计划执行")], + metadata={"conversation_mode": "craft", "plan_brief": brief}, + ) + request = await processor._build_dashboard_request( + msg, + agent_id="agent-1", + user_id=1, + session_key="sk", + thread_id="thr", + meta=msg.metadata or {}, + ) + cfg = request.get("configurable") or {} + assert cfg["conversation_mode"] == "craft" + assert cfg.get("plan_brief") == brief + hint = str(cfg.get("conversation_mode_hint") or "") + assert "Write load.sh" in hint + assert "Approved plan" in hint + from octop.infra.gateway.process.message_keys import UI_HIDDEN_KEY + + human = (request.get("messages") or [None])[0] + assert human is not None + assert human.additional_kwargs.get(UI_HIDDEN_KEY) is True + + +@pytest.mark.asyncio +async def test_iter_turn_chunks_does_not_mutate_agent_tools_disabled() -> None: + """Ask/Plan enforcement is middleware-scoped; processor must not hot-patch tools.""" + mgr = _agent_manager() + processor = _processor(mgr) + + msg = InboundMessage( + channel_id="ws", + channel_type="dashboard", + tenant_id="agent-1", + channel_subject=ChannelSubject(subject_id="1"), + content=[TextContent(text="hi")], + metadata={"conversation_mode": "ask", "thread_id": "thr-1"}, + ) + chunks = [c async for c in processor.iter_turn_chunks(msg)] + assert any(c.get("type") == "token" for c in chunks) + mgr.sync_tools_disabled.assert_not_called() + mgr.sync_effective_tools_disabled.assert_not_called() diff --git a/tests/unit/i18n/test_conversation.py b/tests/unit/i18n/test_conversation.py new file mode 100644 index 000000000..a4c922bd1 --- /dev/null +++ b/tests/unit/i18n/test_conversation.py @@ -0,0 +1,58 @@ +"""tests/unit/i18n/test_conversation.py — Ask/Plan/Craft hint key parity (#616 M7).""" + +from __future__ import annotations + +from octop.i18n import all_keys_for_locale, tr +from octop.i18n.domains.conversation import conversation_mode_system_hint + + +def test_conversation_mode_keys_parity() -> None: + en_keys = {k for k in all_keys_for_locale("en") if k.startswith("conversation.")} + zh_keys = {k for k in all_keys_for_locale("zh") if k.startswith("conversation.")} + assert en_keys == zh_keys + for mode in ("ask", "plan", "craft"): + key = f"conversation.mode.{mode}_system_hint" + assert key in en_keys + assert tr(key, "en") + assert tr(key, "zh") + + +def test_conversation_mode_system_hint_helper() -> None: + ask_en = conversation_mode_system_hint("ask", "en") + ask_zh = conversation_mode_system_hint("ask", "zh") + assert "Ask" in ask_en or "read-only" in ask_en or "Ask mode" in ask_en + assert "仅问答" in ask_zh + assert ask_en != ask_zh + plan_en = conversation_mode_system_hint("plan", "en") + assert "task" in plan_en.lower() or "ACP" in plan_en or "delegate" in plan_en.lower() + + +def test_conversation_mode_tool_blocked_helper() -> None: + from octop.i18n.domains.conversation import conversation_mode_tool_blocked + + en = conversation_mode_tool_blocked("execute", "ask", "en") + zh = conversation_mode_tool_blocked("execute", "ask", "zh") + assert "execute" in en + assert "execute" in zh + assert en != zh + + +def test_conversation_mode_plan_brief_block() -> None: + from octop.i18n.domains.conversation import conversation_mode_plan_brief_block + + en = conversation_mode_plan_brief_block("## Approved plan\n\nDo it.", "en") + zh = conversation_mode_plan_brief_block("## Approved plan\n\nDo it.", "zh") + assert "Approved plan" in en + assert "Do it." in en + assert en != zh or "计划" in zh or "确认" in zh + + +def test_plan_ready_cta_keys_parity() -> None: + """Backend keeps mode system hints; dashboard owns Ready-to-build CTA copy.""" + for mode in ("ask", "plan", "craft"): + assert tr(f"conversation.mode.{mode}_system_hint", "en") + assert tr(f"conversation.mode.{mode}_system_hint", "zh") + assert tr("conversation.mode.tool_blocked", "en", tool_name="x", mode="ask") + assert tr("conversation.mode.tool_blocked", "zh", tool_name="x", mode="ask") + assert tr("conversation.mode.plan_brief_prefix", "en") + assert tr("conversation.mode.plan_brief_prefix", "zh") From 6dcfcd89518ce3bf79cb5d9400ecddde4f439405 Mon Sep 17 00:00:00 2001 From: HUANG Cheng Date: Wed, 9 Sep 2026 16:32:16 +0800 Subject: [PATCH 2/8] fix(chat): harden Plan execute and per-thread conversation mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep composer resources when executing a plan, persist mode per thread, and pass planBrief on new sessions so Plan→Craft handoff does not drop context. --- CHANGELOG.md | 6 ++ dashboard/src/locales/en.json | 4 +- dashboard/src/locales/zh.json | 4 +- .../src/pages/Chat/chatInputCore.partial.less | 16 ++++ .../src/pages/Chat/components/ChatInput.tsx | 5 +- .../components/ChatInputActionsRow.test.tsx | 2 +- .../Chat/components/ChatInputActionsRow.tsx | 8 +- .../Chat/components/PlanReadyCard.test.tsx | 10 ++- .../pages/Chat/components/PlanReadyCard.tsx | 8 +- dashboard/src/pages/Chat/hooks/chatStore.ts | 5 +- dashboard/src/pages/Chat/hooks/sseHelpers.ts | 3 +- dashboard/src/pages/Chat/hooks/useChat.ts | 3 +- .../Chat/hooks/useChatComposerResources.ts | 79 +++++++++++++------ dashboard/src/pages/Chat/hooks/useChatSend.ts | 1 + dashboard/src/pages/Chat/index.tsx | 3 - .../chatMessages.conversationMode.test.ts | 12 ++- .../src/pages/Chat/utils/chatMessages.ts | 11 ++- .../src/pages/Chat/utils/conversationMode.ts | 22 ++++++ .../src/pages/Chat/utils/planArtifact.test.ts | 24 ++++-- .../src/pages/Chat/utils/planArtifact.ts | 36 ++++++--- .../components/CreateFromExpertDrawer.tsx | 19 +---- .../Experts/components/EditAgentDrawer.tsx | 18 +---- src/octop/i18n/en.json | 2 +- src/octop/i18n/zh.json | 2 +- src/octop/infra/agents/plan_artifact.py | 6 +- tests/unit/agents/test_plan_artifact.py | 13 +++ tests/unit/i18n/test_conversation.py | 2 +- 27 files changed, 224 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fea8b15..8259c187f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,16 @@ ### 新增 +<<<<<<< HEAD - 知识库支持下载上传原文;PDF / DOCX / PPTX / XLSX 在预览弹窗中按原排版查看;文档区支持拖拽上传(#591) - 知识库 / 工作区 PDF 预览改用 PDF.js(react-pdf),替代浏览器内置 iframe 查看器 - 技能包支持将选定技能一次性复制到专家工作区,工作区技能也可推送到当前用户有写权限的技能包(#618)。 - 对话权限模式 Ask / Plan / Craft:turn 级 `conversation_mode`、`ConversationModeMiddleware`(工具过滤 + 系统提示 + denylist 硬拒;Ask/Plan 额外拦截 `task` / `acp_runner` / `ask_agent` 防委派逃逸)、agent `default_conversation_mode`;Composer 模式菜单(默认 / 计划 / 仅问答)(#616) +||||||| parent of dd7f7030 (fix(chat): harden Plan execute and per-thread conversation mode) +- 对话权限模式 Ask / Plan / Craft:turn 级 `conversation_mode`、`ConversationModeMiddleware`(工具过滤 + 系统提示 + denylist 硬拒;Ask/Plan 额外拦截 `task` / `acp_runner` / `ask_agent` 防委派逃逸)、agent `default_conversation_mode`;Composer 模式菜单(默认 / 计划 / 仅问答)(#616) +======= +- 对话权限模式 Ask / Plan / Craft:turn 级 `conversation_mode`、`ConversationModeMiddleware`(工具过滤 + 系统提示 + denylist 硬拒;Ask/Plan 额外拦截 `task` / `acp_runner` / `ask_agent` 防委派逃逸)、agent `default_conversation_mode`;Composer 模式菜单(默认 / 计划 / 问答)(#616) +>>>>>>> dd7f7030 (fix(chat): harden Plan execute and per-thread conversation mode) - 想一想模式回合结束后展示「按计划执行 / 继续改计划」;确认后切到默认模式,计划 brief 经 `plan_brief` 由网关注入 system(不落对话气泡)(#616) - 记忆树支持手动新建主题/记忆,以及编辑已有记忆(replace/supersede,保留变更历史)。 - Token 统计支持日期范围筛选(RangePicker 预设 + 自定义起止)并导出 Excel 用量明细(#154) diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 63591c132..ddb271074 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1112,12 +1112,12 @@ "reasoningEffort": "Thinking effort", "reasoningAlways": "Always reasoning", "conversationMode": "Mode", - "conversationModeAsk": "Ask only", + "conversationModeAsk": "Ask", "conversationModePlan": "Plan", "conversationModeCraft": "Default", "conversationModeStatusCraft": "Default mode — execute efficiently and finish the task.", "conversationModeStatusPlan": "Plan mode — outline the approach first, then execute.", - "conversationModeStatusAsk": "Ask-only mode — answer and analyze without changing the environment.", + "conversationModeStatusAsk": "Ask mode — answer and analyze without changing the environment.", "planReadyTitle": "Ready to build?", "planReadyHint": "Switch to Default mode and continue with this plan, or keep refining it.", "planReadyExecute": "Execute plan", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 8b512ec05..d083ea924 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1112,12 +1112,12 @@ "reasoningEffort": "思考强度", "reasoningAlways": "始终推理", "conversationMode": "模式", - "conversationModeAsk": "仅问答", + "conversationModeAsk": "问答", "conversationModePlan": "计划", "conversationModeCraft": "默认", "conversationModeStatusCraft": "当前为默认模式,可高效执行并完成任务。", "conversationModeStatusPlan": "当前为计划模式,先梳理方案与步骤,再动手执行。", - "conversationModeStatusAsk": "当前为仅问答模式,只回答与分析,不改动环境。", + "conversationModeStatusAsk": "当前为问答模式,只回答与分析,不改动环境。", "planReadyTitle": "按计划执行?", "planReadyHint": "确认后将切换到默认模式并带着这份计划继续执行;也可继续改计划。", "planReadyExecute": "按计划执行", diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less index cd0d2f69e..e684d5e42 100644 --- a/dashboard/src/pages/Chat/chatInputCore.partial.less +++ b/dashboard/src/pages/Chat/chatInputCore.partial.less @@ -56,6 +56,22 @@ color: var(--fn-text-secondary); } +.planReadyBrief { + margin: 0 0 12px; + padding: 8px 10px; + max-height: 160px; + overflow: auto; + border-radius: 8px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); + background: var(--fn-bg-layout, #f5f5f5); + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + color: var(--fn-text-secondary); + font-family: inherit; +} + .planReadyActions { display: flex; flex-wrap: wrap; diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 908c494b8..c0f385269 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -18,6 +18,7 @@ import type { ResolvedModel } from "../../../api/types"; import type { KnowledgeBase } from "../../../api/modules/knowledgeBases"; import type { SkillSpec } from "../../Agent/Skills/useSkills"; import type { ChatAgentOption } from "./ExpertAgentAvatar"; +import type { ConversationMode } from "../utils/conversationMode"; import MentionPickerMenu from "./MentionPickerMenu"; import ChatInputPreviewBar from "./ChatInputPreviewBar"; import ChatInputActionsRow from "./ChatInputActionsRow"; @@ -80,8 +81,8 @@ interface ChatInputProps { mode: "auto" | "enabled" | "disabled", effort: string | null, ) => void; - conversationMode?: "ask" | "plan" | "craft"; - onConversationModeChange?: (mode: "ask" | "plan" | "craft") => void; + conversationMode?: ConversationMode; + onConversationModeChange?: (mode: ConversationMode) => void; availableConnectors?: { mcp_server_name: string; label: string; diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index 3e134cf04..3f94aa1d9 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -90,7 +90,7 @@ describe("ChatInputActionsRow conversation mode (#616 M13)", () => { expect(menu).toHaveTextContent("当前为默认模式"); expect(menu).toHaveTextContent("默认"); expect(menu).toHaveTextContent("计划"); - expect(menu).toHaveTextContent("仅问答"); + expect(menu).toHaveTextContent("问答"); fireEvent.click(screen.getAllByTestId("conversation-mode-option-ask")[0]!); expect(onConversationModeChange).toHaveBeenCalledWith("ask"); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index 6c9da1cee..bd070d2c0 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -92,8 +92,8 @@ interface ChatInputActionsRowProps { mode: "auto" | "enabled" | "disabled", effort: string | null, ) => void; - conversationMode?: "ask" | "plan" | "craft"; - onConversationModeChange?: (mode: "ask" | "plan" | "craft") => void; + conversationMode?: ConversationMode; + onConversationModeChange?: (mode: ConversationMode) => void; availableConnectors?: { mcp_server_name: string; label: string; @@ -299,7 +299,7 @@ export default function ChatInputActionsRow({ }; const conversationModeLabel = (mode: ConversationMode) => { - if (mode === "ask") return t("chat.conversationModeAsk", "仅问答"); + if (mode === "ask") return t("chat.conversationModeAsk", "问答"); if (mode === "plan") return t("chat.conversationModePlan", "计划"); return t("chat.conversationModeCraft", "默认"); }; @@ -308,7 +308,7 @@ export default function ChatInputActionsRow({ if (mode === "ask") { return t( "chat.conversationModeStatusAsk", - "当前为仅问答模式,只回答与分析,不改动环境。", + "当前为问答模式,只回答与分析,不改动环境。", ); } if (mode === "plan") { diff --git a/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx b/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx index 0e3b74c52..87bb7d9f8 100644 --- a/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx +++ b/dashboard/src/pages/Chat/components/PlanReadyCard.test.tsx @@ -3,17 +3,23 @@ import { describe, expect, it, vi } from "vitest"; import PlanReadyCard from "./PlanReadyCard"; describe("PlanReadyCard (#616 P6–P8)", () => { - it("shows execute and continue actions", () => { + it("shows execute and continue actions plus brief preview", () => { const onExecute = vi.fn(); const onContinue = vi.fn(); render( , ); expect(screen.getByTestId("plan-ready-card")).toBeInTheDocument(); + expect(screen.getByTestId("plan-ready-brief")).toHaveTextContent("Do it."); + expect(screen.getByTestId("plan-ready-brief")).toHaveTextContent( + "Write file", + ); fireEvent.click(screen.getByTestId("plan-ready-execute")); expect(onExecute).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByTestId("plan-ready-continue")); diff --git a/dashboard/src/pages/Chat/components/PlanReadyCard.tsx b/dashboard/src/pages/Chat/components/PlanReadyCard.tsx index f64ebd184..a340b8ca4 100644 --- a/dashboard/src/pages/Chat/components/PlanReadyCard.tsx +++ b/dashboard/src/pages/Chat/components/PlanReadyCard.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; +import { planBriefPreview } from "../utils/planArtifact"; interface PlanReadyCardProps { brief: string; @@ -14,7 +15,7 @@ export default function PlanReadyCard({ onContinue, }: PlanReadyCardProps) { const { t } = useTranslation(); - void brief; + const preview = planBriefPreview(brief); return (
@@ -26,6 +27,11 @@ export default function PlanReadyCard({ "确认后将切换到默认模式并带着这份计划继续执行;也可继续改计划。", )}

+ {preview ? ( +
+          {preview}
+        
+ ) : null}