diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts b/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts index a7a47c3d5..a6029678a 100644 --- a/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts +++ b/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts @@ -2,7 +2,10 @@ import type { AskUserQuestionAnswer } from "@liveagent/ui/lib/chat/askUserQuesti import { readAskUserQuestionDeadlineAt } from "@liveagent/ui/lib/chat/askUserQuestion"; import type { PlanDecisionAnswer } from "@liveagent/ui/lib/chat/planMode"; import { readPlanApprovedMarker, readPlanPendingMarker } from "@liveagent/ui/lib/chat/planMode"; -import { readToolApprovalPending } from "@liveagent/ui/lib/chat/toolApprovalArgs"; +import { + readToolApprovalPending, + readToolPermissionAutoResolved, +} from "@liveagent/ui/lib/chat/toolApprovalArgs"; import { useSyncExternalStore } from "react"; import { submitAskUserQuestionAnswer } from "../lib/chat/askUserQuestionBridge"; import { @@ -22,6 +25,13 @@ export function usePendingToolApproval( return readToolApprovalPending(toolArguments); } +export function usePermissionAutoResolved( + _toolCallId: string, + toolArguments: Record, +) { + return readToolPermissionAutoResolved(toolArguments); +} + export function readAskUserQuestionDeadline( _toolCallId: string, toolArguments: Record, diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 0508c561c..3f9ceee30 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -18,6 +18,7 @@ import { resolveSearchJumpRowKey, subscribeSearchJumpTarget, } from "@liveagent/ui/lib/chat/searchJumpTarget"; +import { buildToolApprovalRequestJson } from "@liveagent/ui/lib/chat/toolApprovalArgs"; import { useMentionApps } from "@liveagent/ui/lib/chat/useMentionApps"; import { useScrollFollow } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; import { @@ -810,26 +811,29 @@ function useGatewayAppController() { registerToolApprovalDecisionHandler(null); return; } - registerToolApprovalDecisionHandler(async (toolCallId, decision, targetConversationId) => { - // 背景 Pane 显式携带自己的会话 id;主视图缺省仍按当前展示会话路由。 - const conversationIdValue = ( - targetConversationId?.trim() || - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) - ).trim(); - if (!conversationIdValue) { - return { ok: false, message: "No active conversation." }; - } - try { - const response = await api.chatQueueToolApproval( - conversationIdValue, - toolCallId, - JSON.stringify({ decision }), - ); - return { ok: response.accepted, message: response.message || undefined }; - } catch (error) { - return { ok: false, message: asErrorMessage(error, "Failed to submit the decision.") }; - } - }); + registerToolApprovalDecisionHandler( + async (toolCallId, decision, targetConversationId, options) => { + // 背景 Pane 显式携带自己的会话 id;主视图缺省仍按当前展示会话路由。 + const conversationIdValue = ( + targetConversationId?.trim() || + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) + ).trim(); + if (!conversationIdValue) { + return { ok: false, message: "No active conversation." }; + } + try { + // 旧桌面端只读 decision,多出的 scope/reason 会被忽略。 + const response = await api.chatQueueToolApproval( + conversationIdValue, + toolCallId, + buildToolApprovalRequestJson(decision, options), + ); + return { ok: response.accepted, message: response.message || undefined }; + } catch (error) { + return { ok: false, message: asErrorMessage(error, "Failed to submit the decision.") }; + } + }, + ); return () => registerToolApprovalDecisionHandler(null); }, [api]); diff --git a/crates/agent-gateway/web/src/app/hooks/useGatewayChatPresentation.tsx b/crates/agent-gateway/web/src/app/hooks/useGatewayChatPresentation.tsx index e5fe60f54..a0c65a5ac 100644 --- a/crates/agent-gateway/web/src/app/hooks/useGatewayChatPresentation.tsx +++ b/crates/agent-gateway/web/src/app/hooks/useGatewayChatPresentation.tsx @@ -204,7 +204,9 @@ export function useGatewayChatPresentation({ pendingToolApprovals.length > 0 ? ( submitToolApprovalDecision(toolCallId, decision)} + onDecide={(toolCallId, decision, options) => + submitToolApprovalDecision(toolCallId, decision, undefined, options) + } onDecideAll={async (decision) => { for (const item of pendingToolApprovals) { await submitToolApprovalDecision(item.toolCallId, decision); diff --git a/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx b/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx index 85ddbe205..4f330e259 100644 --- a/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx +++ b/crates/agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx @@ -784,8 +784,8 @@ export function GatewayConversationPaneHost(props: GatewayConversationPaneHostPr pendingToolApprovals.length > 0 ? ( - submitToolApprovalDecision(toolCallId, decision, conversationId) + onDecide={(toolCallId, decision, options) => + submitToolApprovalDecision(toolCallId, decision, conversationId, options) } onDecideAll={async (decision) => { for (const item of pendingToolApprovals) { diff --git a/crates/agent-gateway/web/src/lib/chat/toolApprovalBridge.ts b/crates/agent-gateway/web/src/lib/chat/toolApprovalBridge.ts index b4eecd7ab..b95641489 100644 --- a/crates/agent-gateway/web/src/lib/chat/toolApprovalBridge.ts +++ b/crates/agent-gateway/web/src/lib/chat/toolApprovalBridge.ts @@ -1,7 +1,10 @@ // WebUI 端工具审批桥:审批卡片位于 transcript 深处,提交动作由 GatewayApp 注册 // (经 gateway chat_queue.tool_approval 送达桌面端审批挂起表)。模块级单例避免 // 跨多层组件做 props 透传,模式同 askUserQuestionBridge。 -import type { ToolApprovalDecision } from "@liveagent/ui/lib/chat/toolApprovalArgs"; +import type { + ToolApprovalAnswerOptions, + ToolApprovalDecision, +} from "@liveagent/ui/lib/chat/toolApprovalArgs"; export type ToolApprovalSubmitOutcome = { ok: boolean; message?: string }; @@ -9,6 +12,7 @@ type ToolApprovalDecisionHandler = ( toolCallId: string, decision: ToolApprovalDecision, conversationId?: string, + options?: ToolApprovalAnswerOptions, ) => Promise; let handler: ToolApprovalDecisionHandler | null = null; @@ -19,15 +23,17 @@ export function registerToolApprovalDecisionHandler(next: ToolApprovalDecisionHa /** * conversationId 缺省时按"当前展示会话"路由(主视图);多看板的背景 Pane - * 必须显式传自己的会话 id,避免审批被误提交到焦点会话。 + * 必须显式传自己的会话 id,避免审批被误提交到焦点会话。options 带"允许并记住"的 + * 作用域或拒绝说明。 */ export function submitToolApprovalDecision( toolCallId: string, decision: ToolApprovalDecision, conversationId?: string, + options?: ToolApprovalAnswerOptions, ): Promise { if (!handler) { return Promise.resolve({ ok: false, message: "Gateway connection is not ready." }); } - return handler(toolCallId, decision, conversationId); + return handler(toolCallId, decision, conversationId, options); } diff --git a/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts b/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts index 98f93c095..929216459 100644 --- a/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts +++ b/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts @@ -5,6 +5,11 @@ import { answerAskUserQuestion, getAskUserQuestionDeadlineAt, } from "../lib/tools/askUserQuestionTools"; +import { + getPermissionAutoResolved, + getPermissionAutoResolvedVersion, + subscribePermissionAutoResolved, +} from "../lib/tools/permissionAutoResolved"; import { answerPlanDecision, getPlanDecisionVersion, @@ -29,6 +34,18 @@ export function usePendingToolApproval( return Boolean(getPendingToolApproval(toolCallId)); } +export function usePermissionAutoResolved( + toolCallId: string, + _toolArguments: Record, +) { + useSyncExternalStore( + subscribePermissionAutoResolved, + getPermissionAutoResolvedVersion, + getPermissionAutoResolvedVersion, + ); + return getPermissionAutoResolved(toolCallId); +} + export function readAskUserQuestionDeadline( toolCallId: string, _toolArguments: Record, diff --git a/crates/agent-gui/src/lib/kbrain/client.ts b/crates/agent-gui/src/lib/kbrain/client.ts index 2caa372da..a41db1389 100644 --- a/crates/agent-gui/src/lib/kbrain/client.ts +++ b/crates/agent-gui/src/lib/kbrain/client.ts @@ -870,6 +870,7 @@ export function createKBrainClient(inputOptions: KBrainClientOptions = {}) { decision: "allow_once" | "allow_always" | "reject", runId = "", reason?: string, + scope?: string, ): Promise { await request( `/v1/sessions/${encodeURIComponent(conversationId)}/permissions/${encodeURIComponent(permissionId)}`, @@ -882,6 +883,7 @@ export function createKBrainClient(inputOptions: KBrainClientOptions = {}) { permission_id: permissionId, decision, ...(reason ? { reason } : {}), + ...(scope ? { scope } : {}), }, }), }, diff --git a/crates/agent-gui/src/lib/kbrain/turn.ts b/crates/agent-gui/src/lib/kbrain/turn.ts index 5e77168dc..5ce58b402 100644 --- a/crates/agent-gui/src/lib/kbrain/turn.ts +++ b/crates/agent-gui/src/lib/kbrain/turn.ts @@ -135,6 +135,9 @@ export function contextToKBrainMessages(context: Context): KBrainMessage[] { return messages; } +/** How many times a prompt K-brain kept waiting after a refused answer is asked again. */ +const PERMISSION_ANSWER_RETRIES = 3; + /** permission.requested payload. `rule` scopes an allow_always answer, which K-brain only * remembers when it lists that option; older backends offer allow_once/reject only. */ export type KBrainPermissionRequest = { @@ -148,6 +151,26 @@ export type KBrainPermissionRequest = { scope?: string; /** The model tool call waiting on this decision, when K-brain knows it. */ tool_call_id?: string; + /** Shell the command runs in (bash, pwsh, cmd); empty for non-shell tools. */ + shell?: string; + /** What allow_always would remember, from backends with permission_rules. */ + suggestion?: { rules: string[]; default_scope?: string }; +}; + +/** The answer to a permission request: a bare decision, or one with the remember + * scope (allow_always) or the reason given to the model (reject). */ +export type KBrainPermissionAnswer = + | KBrainPermissionDecision + | { decision: KBrainPermissionDecision; scope?: string; reason?: string }; + +export type KBrainPermissionDecision = "allow_once" | "allow_always" | "reject"; + +/** permission.auto_resolved: the backend allowed a call without asking. */ +export type KBrainPermissionAutoResolved = { + toolCallId: string; + source: "rule" | "read_only"; + rule?: string; + scope?: string; }; export type KBrainTurnParams = { @@ -174,7 +197,8 @@ export type KBrainTurnParams = { onPermissionRequest?: ( request: KBrainPermissionRequest, signal?: AbortSignal, - ) => Promise<"allow_once" | "allow_always" | "reject">; + ) => Promise; + onPermissionAutoResolved?: (resolved: KBrainPermissionAutoResolved) => void; applyConversationState?: (state: ConversationViewState) => void; getConversationState?: () => ConversationViewState; baseUrl?: string; @@ -900,25 +924,40 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise; - try { - decisionPromise = Promise.resolve( - params.onPermissionRequest(request, permissionController.signal), - ); - } catch (error) { - decisionPromise = Promise.reject(error); - } - void decisionPromise - .then((decision) => { + const onPermissionRequest = params.onPermissionRequest; + const answerPermission = async () => { + for (let attempt = 0; ; attempt += 1) { + const answer = await onPermissionRequest(request, permissionController.signal); if (permissionController.signal.aborted) return; - return client - .resolvePermission(kbrainSessionId, request.permission_id, decision, accepted.run_id) - .catch((error) => { - // A concurrent session grant can consume this waiter before its SSE resolution arrives. - if ((error as { status?: number }).status === 404) return; - throw error; - }); - }) + const { decision, scope, reason } = permissionAnswerFields(answer); + try { + await client.resolvePermission( + kbrainSessionId, + request.permission_id, + decision, + accepted.run_id, + reason, + scope, + ); + return; + } catch (error) { + const status = (error as { status?: number }).status; + // A concurrent session grant can consume this waiter before its SSE resolution arrives. + if (status === 404) return; + // K-brain answers 400 and keeps the prompt waiting when it cannot store the + // remembered rules (a scope it cannot keep, a failed write): ask again. + if ( + status === 400 && + attempt < PERMISSION_ANSWER_RETRIES && + !permissionController.signal.aborted + ) { + continue; + } + throw error; + } + } + }; + void answerPermission() .catch((error) => { if (permissionController.signal.aborted) return; permissionFailure = error instanceof Error ? error : new Error(String(error)); @@ -932,6 +971,25 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise(event); + const toolCallId = resolved?.tool_call_id?.trim(); + const source = resolved?.source; + if (toolCallId && (source === "rule" || source === "read_only")) { + params.onPermissionAutoResolved?.({ + toolCallId, + source, + ...(resolved?.rule ? { rule: resolved.rule } : {}), + ...(resolved?.scope ? { scope: resolved.scope } : {}), + }); + } + break; + } case "permission.resolved": { const resolution = eventPayload<{ permission_id?: string }>(event); if (resolution?.permission_id) { @@ -1050,3 +1108,20 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise(); +const listeners = new Set<() => void>(); +let version = 0; + +export function recordPermissionAutoResolved(toolCallId: string, value: unknown): boolean { + const key = toolCallId.trim(); + const normalized = normalizeToolPermissionAutoResolved(value); + if (!key || !normalized) return false; + byToolCallId.delete(key); + byToolCallId.set(key, normalized); + while (byToolCallId.size > MAX_ENTRIES) { + const oldest = byToolCallId.keys().next().value; + if (oldest === undefined) break; + byToolCallId.delete(oldest); + } + version += 1; + for (const listener of listeners) listener(); + return true; +} + +export function getPermissionAutoResolved(toolCallId: string): ToolPermissionAutoResolved | null { + return byToolCallId.get(toolCallId.trim()) ?? null; +} + +export function subscribePermissionAutoResolved(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getPermissionAutoResolvedVersion(): number { + return version; +} diff --git a/crates/agent-gui/src/lib/tools/toolApproval.ts b/crates/agent-gui/src/lib/tools/toolApproval.ts index b19d47fe5..0b3993654 100644 --- a/crates/agent-gui/src/lib/tools/toolApproval.ts +++ b/crates/agent-gui/src/lib/tools/toolApproval.ts @@ -8,6 +8,13 @@ // answerToolApproval(第 3 步接线)。 import { ASK_USER_QUESTION_TIMEOUT_MS } from "@liveagent/ui/lib/chat/askUserQuestion"; +import { + normalizeToolApprovalReason, + normalizeToolApprovalRules, + normalizeToolApprovalScope, + type ToolApprovalAnswerOptions, + type ToolApprovalRememberScope, +} from "@liveagent/ui/lib/chat/toolApprovalArgs"; /** 审批窗口毫秒数:复用 AskUserQuestion 的时长常量,行为口径一致。 */ export const TOOL_APPROVAL_TIMEOUT_MS = ASK_USER_QUESTION_TIMEOUT_MS; @@ -16,7 +23,7 @@ export const TOOL_APPROVAL_TIMEOUT_MS = ASK_USER_QUESTION_TIMEOUT_MS; export type ToolApprovalDecision = "approve" | "deny" | "approve_session"; export type ToolApprovalSettlement = - | { kind: "decided"; decision: ToolApprovalDecision } + | ({ kind: "decided"; decision: ToolApprovalDecision } & ToolApprovalAnswerOptions) | { kind: "timeout" } | { kind: "cancelled" }; @@ -25,14 +32,17 @@ type PendingToolApproval = { toolName: string; /** 命令/参数摘要(供审批栏统一展示;Bash 显示命令等)。 */ summary: string; - /** 权威应答截止时间戳(毫秒);卡片倒计时与超时兜底同源。 */ - deadlineAt: number; + /** 权威应答截止时间戳(毫秒);卡片倒计时与超时兜底同源。null 表示一直等到用户决定。 */ + deadlineAt: number | null; /** 后端会记住 approve_session 时才为真;记忆由 K-brain 持有,前端不缓存。 */ allowSession: boolean; /** approve_session 生效的授权范围(如 computer_exec 的 call:click)。 */ sessionScope: string; /** 后端声明:这次允许即对本会话同类操作生效(如 Computer Use)。 */ sessionWide: boolean; + /** 允许并记住时后端会存的规则;空表示只能按本会话授权(或不能记住)。 */ + rememberRules: string[]; + defaultScope: ToolApprovalRememberScope; settle: (settlement: ToolApprovalSettlement) => void; }; @@ -104,10 +114,12 @@ export type PendingToolApprovalSummary = { toolCallId: string; toolName: string; summary: string; - deadlineAt: number; + deadlineAt: number | null; allowSession: boolean; sessionScope: string; sessionWide: boolean; + rememberRules: string[]; + defaultScope: ToolApprovalRememberScope; }; export function listPendingToolApprovalsForConversation( @@ -125,6 +137,8 @@ export function listPendingToolApprovalsForConversation( allowSession: pending.allowSession, sessionScope: pending.sessionScope, sessionWide: pending.sessionWide, + rememberRules: pending.rememberRules, + defaultScope: pending.defaultScope, }); } } @@ -150,7 +164,7 @@ export type AnswerToolApprovalOutcome = { ok: boolean; message?: string }; export function answerToolApproval( toolCallId: string, decision: ToolApprovalDecision, - options?: { conversationId?: string }, + options?: { conversationId?: string } & ToolApprovalAnswerOptions, ): AnswerToolApprovalOutcome { const pending = pendingByToolCallId.get(toolCallId.trim()); if (!pending) { @@ -160,7 +174,7 @@ export function answerToolApproval( if (expectedConversationId && expectedConversationId !== pending.conversationId) { return { ok: false, message: "Approval belongs to a different conversation." }; } - pending.settle({ kind: "decided", decision }); + pending.settle({ kind: "decided", decision, scope: options?.scope, reason: options?.reason }); return { ok: true }; } @@ -178,8 +192,10 @@ export function cancelPendingToolApprovalsForConversation(conversationId: string /** * 挂起等待用户对一次工具调用作出审批决定。由 K-brain 的 permission.requested 调用。 * - AbortSignal(turn 停止)→ 落定为 cancelled。 - * - 超过窗口 → 落定为 timeout(门控按“拒绝”处理)。 + * - 超过窗口 → 落定为 timeout(门控按“拒绝”处理)。timeoutMs 为 null 时不设窗口, + * 一直等到用户决定或中止(K-brain 自己不会超时,前端也不替用户拒绝)。 * - allowSession 为假时 approve_session 按 approve 落定:后端不会记住,不能让用户以为记住了。 + * - scope 只随有规则可记的 approve_session 落定;reason 只随 deny 落定。 */ export function requestToolApproval(params: { toolCallId: string; @@ -189,13 +205,16 @@ export function requestToolApproval(params: { allowSession?: boolean; sessionScope?: string; sessionWide?: boolean; + rememberRules?: string[]; + defaultScope?: string; signal?: AbortSignal; - timeoutMs?: number; + timeoutMs?: number | null; }): Promise { const toolCallId = params.toolCallId.trim(); const conversationId = params.conversationId.trim(); - const timeoutMs = params.timeoutMs ?? TOOL_APPROVAL_TIMEOUT_MS; - const deadlineAt = Date.now() + timeoutMs; + const timeoutMs = + params.timeoutMs === null ? null : (params.timeoutMs ?? TOOL_APPROVAL_TIMEOUT_MS); + const deadlineAt = timeoutMs === null ? null : Date.now() + timeoutMs; if (params.signal?.aborted) { return Promise.resolve({ kind: "cancelled" }); @@ -208,18 +227,15 @@ export function requestToolApproval(params: { pendingByToolCallId.delete(toolCallId); } params.signal?.removeEventListener("abort", onAbort); - clearTimeout(timeoutId); + if (timeoutId !== undefined) clearTimeout(timeoutId); emitChange(conversationId); - resolve( - settlement.kind === "decided" && - settlement.decision === "approve_session" && - !pending.allowSession - ? { kind: "decided", decision: "approve" } - : settlement, - ); + resolve(settlement.kind === "decided" ? decidedSettlement(settlement, pending) : settlement); }; const onAbort = () => settle({ kind: "cancelled" }); - const timeoutId = setTimeout(() => settle({ kind: "timeout" }), Math.max(0, timeoutMs)); + const timeoutId = + timeoutMs === null + ? undefined + : setTimeout(() => settle({ kind: "timeout" }), Math.max(0, timeoutMs)); const pending: PendingToolApproval = { conversationId, toolName: params.toolName, @@ -228,6 +244,8 @@ export function requestToolApproval(params: { allowSession: params.allowSession === true, sessionScope: params.sessionScope?.trim() ?? "", sessionWide: params.sessionWide === true, + rememberRules: normalizeToolApprovalRules(params.rememberRules), + defaultScope: normalizeToolApprovalScope(params.defaultScope) ?? "session", settle, }; pendingByToolCallId.set(toolCallId, pending); @@ -235,3 +253,22 @@ export function requestToolApproval(params: { emitChange(conversationId); }); } + +// Only what the backend can act on survives: a scope needs rules to remember, a +// reason belongs to a denial, and approve_session without a session grant is once. +function decidedSettlement( + settlement: Extract, + pending: PendingToolApproval, +): ToolApprovalSettlement { + if (settlement.decision === "approve_session") { + if (!pending.allowSession) return { kind: "decided", decision: "approve" }; + const scope = + pending.rememberRules.length > 0 ? normalizeToolApprovalScope(settlement.scope) : undefined; + return { kind: "decided", decision: "approve_session", ...(scope ? { scope } : {}) }; + } + if (settlement.decision === "deny") { + const reason = normalizeToolApprovalReason(settlement.reason); + return { kind: "decided", decision: "deny", ...(reason ? { reason } : {}) }; + } + return { kind: "decided", decision: "approve" }; +} diff --git a/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx b/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx index e6824c8a6..d6cff0a0f 100644 --- a/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx +++ b/crates/agent-gui/src/pages/chat/components/PendingToolApprovalBar.tsx @@ -35,8 +35,8 @@ function PendingToolApprovalBarContent(props: { return ( - Promise.resolve(answerToolApproval(toolCallId, decision, { conversationId })) + onDecide={(toolCallId, decision, options) => + Promise.resolve(answerToolApproval(toolCallId, decision, { conversationId, ...options })) } onDecideAll={async (decision) => { for (const item of pending) { diff --git a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts index 8cf79a87e..1df1b0dba 100644 --- a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts @@ -5,6 +5,7 @@ import type { MentionComposerHandle, } from "@liveagent/ui/components/chat/MentionComposer"; import { normalizeConversationMentionReferences } from "@liveagent/ui/lib/chat/mentionReferences"; +import { parseToolApprovalRequestJson } from "@liveagent/ui/lib/chat/toolApprovalArgs"; import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; import type { ChatQueueTurnPreview } from "@liveagent/ui/pages/chat/ChatComposerBar"; import { @@ -1028,28 +1029,23 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { } // WebUI 对工具审批卡片的决定:itemId 即 toolCallId,request_json 携带 - // {"decision":"approve"|"deny"|"approve_session"},落到桌面审批挂起表。 + // {"decision":"approve"|"deny"|"approve_session","scope"?,"reason"?},落到桌面 + // 审批挂起表;scope 是"允许并记住"的作用域,reason 是拒绝时交给模型的说明。 if (action === "tool_approval") { if (!itemId) { fail("tool_approval requires item_id", "invalid_request"); return; } - let decision: unknown; - try { - decision = JSON.parse(request.requestJson || "{}"); - } catch { - fail("invalid tool approval payload", "invalid_payload"); - return; - } - const raw = - decision && typeof decision === "object" - ? (decision as { decision?: unknown }).decision - : decision; - if (raw !== "approve" && raw !== "deny" && raw !== "approve_session") { + const parsed = parseToolApprovalRequestJson(request.requestJson); + if (!parsed) { fail("invalid tool approval decision", "invalid_payload"); return; } - const outcome = answerToolApproval(itemId, raw, { conversationId }); + const outcome = answerToolApproval(itemId, parsed.decision, { + conversationId, + scope: parsed.scope, + reason: parsed.reason, + }); if (!outcome.ok) { fail(outcome.message || "approval not pending", "not_found"); return; diff --git a/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts b/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts index 45d55af04..df383a8b5 100644 --- a/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts +++ b/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts @@ -10,12 +10,17 @@ import { EXIT_PLAN_MODE_TOOL_NAME, } from "@liveagent/ui/lib/chat/planMode"; import { + clampApprovalRules, + clampApprovalText, TOOL_APPROVAL_ALLOW_SESSION_ARG, TOOL_APPROVAL_DEADLINE_ARG, + TOOL_APPROVAL_DEFAULT_SCOPE_ARG, TOOL_APPROVAL_PENDING_ARG, + TOOL_APPROVAL_REMEMBER_RULES_ARG, TOOL_APPROVAL_SESSION_SCOPE_ARG, TOOL_APPROVAL_SESSION_WIDE_ARG, TOOL_APPROVAL_SUMMARY_ARG, + TOOL_PERMISSION_AUTO_RESOLVED_ARG, } from "@liveagent/ui/lib/chat/toolApprovalArgs"; import { countTextLines, @@ -26,6 +31,7 @@ import { } from "@liveagent/ui/lib/chat/toolPreview"; import { summarizeToolCall } from "../../../lib/chat/messages/uiMessages"; import { ensureAskUserQuestionDeadlineAt } from "../../../lib/tools/askUserQuestionTools"; +import { getPermissionAutoResolved } from "../../../lib/tools/permissionAutoResolved"; import { isPlanApprovalToolCall, isPlanDecisionPending } from "../../../lib/tools/planModeTools"; import { getPendingToolApproval } from "../../../lib/tools/toolApproval"; @@ -142,21 +148,44 @@ export function buildGatewayToolCallPreviewArguments( // 不再带此标记,卡片随之隐藏。__ 前缀合成参数不入展示、不影响本地执行。 // 授权范围随标记同步,远端审批栏与桌面一致地说明"允许"的效果、决定是否提供 // "本会话始终允许";摘要优先用审批登记的文本(K-brain 下含 AI 审查说明)。 + // K-brain 审批不设截止时间(deadlineAt 为 null),不发截止标记,远端也不显示倒计时; + // 有可记住的规则时带上规则与默认作用域,远端审批栏据此提供"允许并记住"。 + // 后端没有询问就放行的调用(permission.auto_resolved)带上来源标记。 const pending = toolCall.id ? getPendingToolApproval(toolCall.id) : null; + const autoResolved = toolCall.id ? getPermissionAutoResolved(toolCall.id) : null; const approvalOverlay: Record | null = pending ? { [TOOL_APPROVAL_PENDING_ARG]: true, - [TOOL_APPROVAL_DEADLINE_ARG]: pending.deadlineAt, + ...(pending.deadlineAt === null + ? {} + : { [TOOL_APPROVAL_DEADLINE_ARG]: pending.deadlineAt }), [TOOL_APPROVAL_SUMMARY_ARG]: pending.summary ? clampApprovalSummary(pending.summary) : summarizeToolCallForApproval(toolCall), [TOOL_APPROVAL_ALLOW_SESSION_ARG]: pending.allowSession, [TOOL_APPROVAL_SESSION_WIDE_ARG]: pending.sessionWide, ...(pending.sessionScope - ? { [TOOL_APPROVAL_SESSION_SCOPE_ARG]: pending.sessionScope } + ? { + [TOOL_APPROVAL_SESSION_SCOPE_ARG]: clampApprovalText( + pending.sessionScope, + TOOL_APPROVAL_SUMMARY_MAX_CHARS, + ), + } : {}), + ...(pending.rememberRules.length > 0 + ? { + [TOOL_APPROVAL_REMEMBER_RULES_ARG]: clampApprovalRules( + pending.rememberRules, + TOOL_APPROVAL_SUMMARY_MAX_CHARS, + ), + [TOOL_APPROVAL_DEFAULT_SCOPE_ARG]: pending.defaultScope, + } + : {}), + ...(autoResolved ? { [TOOL_PERMISSION_AUTO_RESOLVED_ARG]: autoResolved } : {}), } - : null; + : autoResolved + ? { [TOOL_PERMISSION_AUTO_RESOLVED_ARG]: autoResolved } + : null; // AskUserQuestion:附带权威应答截止时间,WebUI 卡片倒计时与桌面计时同源 //(execute 挂起时复用同一预置值;见 askUserQuestionTools)。ask 工具只读、 // 永不进入审批门,故此处无需叠加 approvalOverlay。 diff --git a/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts index b112557e2..5260f552f 100644 --- a/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts @@ -1,6 +1,7 @@ import type { AssistantMessage, ToolCall, ToolResultMessage } from "@liveagent/app/lib/agentTypes"; import type { ComposerTurnCommand } from "@liveagent/ui/lib/chat/composerCommands"; import { resolveKBrainPermissionMode } from "@liveagent/ui/lib/chat/permissionModes"; +import { clampApprovalText } from "@liveagent/ui/lib/chat/toolApprovalArgs"; import { appendMessagesToConversation } from "../../../lib/chat/conversation/conversationState"; import { buildConversationStateFromWindow } from "../../../lib/chat/history/chatHistory"; import { @@ -22,7 +23,7 @@ import { import { getKBrainHistoryWindow } from "../../../lib/kbrain/history"; import { getKBrainSessionId } from "../../../lib/kbrain/mapping"; import { canonicalKBrainToolPolicies } from "../../../lib/kbrain/toolPolicies"; -import { runKBrainTurn } from "../../../lib/kbrain/turn"; +import { type KBrainPermissionAutoResolved, runKBrainTurn } from "../../../lib/kbrain/turn"; import type { KBrainGoalState, KBrainQuestionAnswer, @@ -34,12 +35,17 @@ import { withUploadStagingRoot, } from "../../../lib/kbrain/uploadStagingRoot"; import { requestBackendQuestion } from "../../../lib/tools/askUserQuestionTools"; +import { recordPermissionAutoResolved } from "../../../lib/tools/permissionAutoResolved"; import { projectBackendPlan } from "../../../lib/tools/planModeTools"; import { hasPendingToolApproval, requestToolApproval } from "../../../lib/tools/toolApproval"; import { buildGatewayToolCallPreviewArguments } from "./gatewayToolPreview"; import type { RunAgentConversationTurnParams } from "./runAgentConversationTurn"; import type { RunTextConversationTurnParams } from "./runTextConversationTurn"; +// The bar menu names the rules "allow for this session" stores; an interpreter rule can be +// as long as the command, so the label is cut. +const SESSION_SCOPE_LABEL_MAX_CHARS = 300; + type Params = RunAgentConversationTurnParams | RunTextConversationTurnParams; function canonicalRunOptions(params: Params, uploadsRoot?: string): KBrainRunOptions { @@ -140,6 +146,9 @@ export async function runKBrainConversationTurn(params: Params): Promise { gatewayBridgeEvents.queueToolStatus(value); } }; + // Calls K-brain allowed without asking, keyed by tool call; the marker can arrive + // before or after the call itself. + const autoResolvedCallIds = new Set(); const onToolCall = (call: ToolCall) => { calls.set(call.id, call); callRounds.set(call.id, round); @@ -151,11 +160,28 @@ export async function runKBrainConversationTurn(params: Params): Promise { type: "tool_call", id: call.id, name: call.name, - arguments: call.arguments, + arguments: autoResolvedCallIds.has(call.id) + ? buildGatewayToolCallPreviewArguments(call) + : call.arguments, conversation_id: params.conversationId, round, }); }; + const onPermissionAutoResolved = (resolved: KBrainPermissionAutoResolved) => { + const { toolCallId, ...marker } = resolved; + if (!recordPermissionAutoResolved(toolCallId, marker)) return; + autoResolvedCallIds.add(toolCallId); + const call = calls.get(toolCallId); + if (!call) return; + gatewayBridgeEvents.queueEvent({ + type: "tool_call", + id: call.id, + name: call.name, + arguments: buildGatewayToolCallPreviewArguments(call), + conversation_id: params.conversationId, + round: callRounds.get(call.id) ?? round, + }); + }; const projectToolResult = (call: ToolCall, result: ToolResultMessage) => { const prior = results.get(call.id); const enriched = @@ -290,24 +316,41 @@ export async function runKBrainConversationTurn(params: Params): Promise { round: callRounds.get(tiedCall.id) ?? round, }); }; + // Backends with permission_rules say which rules "allow and remember" stores; + // the session item then names those rules instead of the prompt's first + // segment. K-brain waits for the answer, so the approval has no deadline. + const rememberRules = request.suggestion?.rules ?? []; const approval = requestToolApproval({ toolCallId: tiedCall?.id ?? request.permission_id, toolName: request.tool, summary: [request.command, request.description].filter(Boolean).join("\n\n"), conversationId: params.conversationId, allowSession: request.options?.some((option) => option.id === "allow_always") === true, - sessionScope: request.rule, + sessionScope: + rememberRules.length > 0 + ? clampApprovalText(rememberRules.join(", "), SESSION_SCOPE_LABEL_MAX_CHARS) + : request.rule, sessionWide: request.scope === "session", + rememberRules, + defaultScope: request.suggestion?.default_scope, signal: signal ? AbortSignal.any([approvalController.signal, signal]) : approvalController.signal, + timeoutMs: null, }); syncGatewayCall(); const settlement = await approval; syncGatewayCall(); - if (settlement.kind !== "decided" || settlement.decision === "deny") return "reject"; - return settlement.decision === "approve_session" ? "allow_always" : "allow_once"; + if (settlement.kind !== "decided") return "reject"; + if (settlement.decision === "deny") { + return settlement.reason ? { decision: "reject", reason: settlement.reason } : "reject"; + } + if (settlement.decision !== "approve_session") return "allow_once"; + // The bar's session item stays a session rule unless a scope was picked. + const scope = settlement.scope ?? (rememberRules.length > 0 ? "session" : undefined); + return scope ? { decision: "allow_always", scope } : "allow_always"; }, + onPermissionAutoResolved, onSubagent: (subagent) => { const id = `kbrain-subagent:${subagent.id}`; const call: ToolCall = { diff --git a/crates/agent-gui/test/chat/kbrain-approval-remember.test.mjs b/crates/agent-gui/test/chat/kbrain-approval-remember.test.mjs new file mode 100644 index 000000000..358d949ef --- /dev/null +++ b/crates/agent-gui/test/chat/kbrain-approval-remember.test.mjs @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import http from "node:http"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-2a: a K-brain approval carries the rules "allow and remember" would store and +// their default scope to the desktop bar and to WebUI, waits without a deadline, and +// answers with the scope or the denial reason; permission.auto_resolved marks the call. + +const loader = createTsModuleLoader(); +const { runAgentConversationTurn } = loader.loadModule("src/pages/chat/turns/runAgentConversationTurn.ts"); +const { createConversationStateFromContext } = loader.loadModule("src/lib/chat/conversation/conversationState.ts"); +const approval = loader.loadModule("src/lib/tools/toolApproval.ts"); +const autoResolved = loader.loadModule("src/lib/tools/permissionAutoResolved.ts"); +const { buildGatewayToolCallPreviewArguments } = loader.loadModule("src/pages/chat/turns/gatewayToolPreview.ts"); +const args = loader.loadModule("../agent-ui/src/lib/chat/toolApprovalArgs.ts"); +const { toolCallArgsForDisplay } = loader.loadModule("../agent-ui/src/lib/chat/uiMessages.ts"); +const { setKBrainRuntimeConnection, clearKBrainRuntimeConnection } = loader.loadModule("src/lib/kbrain/runtimeConnection.ts"); + +test("the Gateway overlay carries remember rules and no deadline for a waiting K-brain approval", async () => { + const pending = approval.requestToolApproval({ + toolCallId: "overlay-1", + toolName: "Bash", + summary: "npm test", + conversationId: "conv-overlay", + allowSession: true, + sessionScope: "Bash(npm test:*)", + rememberRules: ["Bash(npm test:*)"], + defaultScope: "project", + timeoutMs: null, + }); + const overlay = buildGatewayToolCallPreviewArguments({ id: "overlay-1", name: "Bash", arguments: { command: "npm test" } }); + assert.deepEqual(overlay[args.TOOL_APPROVAL_REMEMBER_RULES_ARG], ["Bash(npm test:*)"]); + assert.equal(overlay[args.TOOL_APPROVAL_DEFAULT_SCOPE_ARG], "project"); + assert.equal(args.TOOL_APPROVAL_DEADLINE_ARG in overlay, false, "no deadline is sent for an approval that waits"); + const remote = args.pendingToolApprovalFromToolCall({ id: "overlay-1", name: "Bash", arguments: overlay }); + assert.deepEqual( + { rememberRules: remote.rememberRules, defaultScope: remote.defaultScope, deadlineAt: remote.deadlineAt }, + { rememberRules: ["Bash(npm test:*)"], defaultScope: "project", deadlineAt: undefined }, + ); + approval.answerToolApproval("overlay-1", "deny"); + await pending; +}); + +test("an approval without rules sends no remember fields, and old payloads read as before", () => { + const remote = args.pendingToolApprovalFromToolCall({ + id: "old-1", + name: "computer_exec", + arguments: { [args.TOOL_APPROVAL_PENDING_ARG]: true, [args.TOOL_APPROVAL_DEADLINE_ARG]: 123, [args.TOOL_APPROVAL_ALLOW_SESSION_ARG]: true }, + }); + assert.equal(remote.rememberRules, undefined); + assert.equal(remote.defaultScope, undefined); + assert.equal(remote.deadlineAt, 123); +}); + +test("an auto-resolved call is marked on the Gateway copy and hidden from the shown arguments", () => { + autoResolved.recordPermissionAutoResolved("auto-1", { source: "rule", rule: "Bash(npm test:*)", scope: "project" }); + const overlay = buildGatewayToolCallPreviewArguments({ id: "auto-1", name: "Bash", arguments: { command: "npm test" } }); + assert.deepEqual(args.readToolPermissionAutoResolved(overlay), { source: "rule", rule: "Bash(npm test:*)", scope: "project" }); + assert.deepEqual(autoResolved.getPermissionAutoResolved("auto-1"), { source: "rule", rule: "Bash(npm test:*)", scope: "project" }); + const shown = toolCallArgsForDisplay({ + type: "toolCall", + id: "x", + name: "mcp__fs__write", + arguments: { + path: "a", + [args.TOOL_APPROVAL_REMEMBER_RULES_ARG]: ["r"], + [args.TOOL_APPROVAL_DEFAULT_SCOPE_ARG]: "project", + [args.TOOL_PERMISSION_AUTO_RESOLVED_ARG]: { source: "read_only" }, + }, + }); + assert.deepEqual(Object.keys(shown), ["path"]); +}); + +test("unknown auto-resolved sources are ignored", () => { + autoResolved.recordPermissionAutoResolved("auto-2", { source: "maybe" }); + assert.equal(autoResolved.getPermissionAutoResolved("auto-2"), null); +}); + +test("the WebUI decision payload carries scope and reason and still parses old payloads", () => { + assert.equal(args.buildToolApprovalRequestJson("approve_session", { scope: "project", reason: "x" }), '{"decision":"approve_session","scope":"project"}'); + assert.equal(args.buildToolApprovalRequestJson("deny", { scope: "project", reason: " no " }), '{"decision":"deny","reason":"no"}'); + assert.equal(args.buildToolApprovalRequestJson("approve"), '{"decision":"approve"}'); + assert.deepEqual(args.parseToolApprovalRequestJson('{"decision":"approve_session","scope":"user"}'), { decision: "approve_session", scope: "user" }); + assert.deepEqual(args.parseToolApprovalRequestJson('{"decision":"deny","reason":"use utils.ts"}'), { decision: "deny", reason: "use utils.ts" }); + assert.deepEqual(args.parseToolApprovalRequestJson('{"decision":"approve_session","scope":"galaxy"}'), { decision: "approve_session" }); + assert.deepEqual(args.parseToolApprovalRequestJson('"approve"'), { decision: "approve" }); + assert.equal(args.parseToolApprovalRequestJson('{"decision":"maybe"}'), null); + assert.equal(args.parseToolApprovalRequestJson("{oops"), null); +}); + +async function runTurn(t, { answer, events }) { + const conversationId = `conv-${Math.random().toString(36).slice(2)}`; + const posts = []; + let stream; + let sequence = 0; + const event = (type, payload) => stream.write(`data: ${JSON.stringify({ + version: "kbrain.agent.v1", seq: ++sequence, conversation_id: "session", run_id: "run", type, + created_at: new Date().toISOString(), payload, + })}\n\n`); + const finish = () => { + event("assistant.message.created", { role: "assistant", content: [{ type: "text", text: "done" }] }); + event("run.completed", { state: "completed" }); + stream.end(); + }; + const server = http.createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + const json = (value, code = 200) => { + response.writeHead(code, { "Content-Type": "application/json" }); + response.end(JSON.stringify(value)); + }; + if (request.url === "/v1/sessions") return json({ id: "session" }, 201); + if (request.url === "/v1/sessions/session/runs") return json({ version: "kbrain.agent.v1", conversation_id: "session", run_id: "run", accepted_seq: 1 }, 202); + if (request.url === "/v1/sessions/session" || request.url.startsWith("/v1/sessions/session/history")) { + const session = { id: "session", revision: "r", title: "t", model: { provider: "fixture", model: "model" }, created_at: "2026-11-01T00:00:00Z", updated_at: "2026-11-01T00:00:01Z", message_count: 0, last_seq: sequence, messages: [] }; + return json(request.url.includes("/history") ? { session, revision: "r", oldest_offset: 0, has_more_before: false, total_message_count: 0, active_messages: [] } : session); + } + if (request.url.startsWith("/v1/sessions/session/events")) { + stream = response; + response.writeHead(200, { "Content-Type": "text/event-stream" }); + sequence = Number(new URL(request.url, "http://localhost").searchParams.get("after_seq") ?? 0); + events(event); + if (!events.waitForAnswer) finish(); + return; + } + if (request.url.startsWith("/v1/sessions/session/permissions/")) { + posts.push(JSON.parse(body).decision); + json({ ok: true }); + event("permission.resolved", { permission_id: JSON.parse(body).decision.permission_id }); + finish(); + return; + } + json({ error: `Unexpected path ${request.url}` }, 404); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const originalStorage = globalThis.localStorage; + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + key: (index) => Array.from(storage.keys())[index] ?? null, + get length() { return storage.size; }, + }; + setKBrainRuntimeConnection({ baseUrl: origin, token: "fixture-token", protocolVersion: "kbrain.agent.v1" }); + t.after(() => { + clearKBrainRuntimeConnection(); + if (originalStorage === undefined) delete globalThis.localStorage; + else globalThis.localStorage = originalStorage; + server.closeAllConnections(); + server.close(); + }); + const listed = []; + const dispose = approval.subscribeToolApprovalsForConversation(conversationId, () => queueMicrotask(() => { + for (const pending of approval.listPendingToolApprovalsForConversation(conversationId)) { + listed.push(pending); + const [decision, options] = answer; + approval.answerToolApproval(pending.toolCallId, decision, { conversationId, ...options }); + } + })); + t.after(dispose); + const context = { systemPrompt: "system", messages: [{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }] }; + let state = createConversationStateFromContext(context); + let rounds = []; + const gatewayEvents = []; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + t.after(() => clearTimeout(timeout)); + await runAgentConversationTurn({ + providerId: "codex", model: "model", selectedModel: { customProviderId: "fixture", model: "model" }, + runtime: { backend: "kbrain", baseUrl: "https://must-not-be-called.invalid", apiKey: "unused" }, sessionId: "frontend-session", conversationId, + trajectoryMessageId: "message", conversationCwd: "/workspace", effectiveWorkdir: "/workspace", createdAt: 1, fallbackTitle: "t", titlePromise: null, + transcriptStore: {}, cancellation: { userStop: controller }, + gatewayBridgeEvents: { queueToken() {}, queueEvent(value) { gatewayEvents.push(value); }, queueToolStatus() {}, emitError() {} }, + hookLifecycle: Object.fromEntries(["startAgent", "startTurn", "ensureMessageEnded", "endTurn", "endAgent"].map((key) => [key, () => {}])), + buildPreparedContext: () => context, getNextConversationState: () => state, + applyConversationState: (value) => { state = value; }, + batchLiveRoundsUpdate: (update) => { rounds = update(rounds); }, + updateToolStatus() {}, updateGatewayBridgeToolStatus() {}, freezeGatewayFinalProjection() {}, settleLiveTranscript() {}, + persistConversationWithHistorySync: async () => true, + }); + return { posts, listed, gatewayEvents }; +} + +const permissionEvents = (suggestion) => { + const emit = (event) => { + event("tool.call", { tool_call: { id: "call", name: "Bash", arguments: { command: "npm test && echo done" } } }); + event("permission.requested", { + permission_id: "permission", tool: "Bash", command: "npm test && echo done", rule: "npm test", shell: "bash", tool_call_id: "call", + options: [{ id: "allow_once", kind: "allow_once" }, { id: "allow_always", label: "Allow and remember", kind: "allow_always" }, { id: "reject", kind: "reject_once" }], + ...(suggestion ? { suggestion } : {}), + }); + }; + emit.waitForAnswer = true; + return emit; +}; + +test("a suggestion reaches the bar, the approval has no deadline, and approve_session remembers for this session", async (t) => { + const { posts, listed, gatewayEvents } = await runTurn(t, { + answer: ["approve_session"], + events: permissionEvents({ rules: ["Bash(npm test:*)", "Bash(echo:*)"], default_scope: "project" }), + }); + assert.equal(listed[0].deadlineAt, null); + assert.deepEqual(listed[0].rememberRules, ["Bash(npm test:*)", "Bash(echo:*)"]); + assert.equal(listed[0].defaultScope, "project"); + // The session item names what will be stored, not just the prompt's first segment. + assert.equal(listed[0].sessionScope, "Bash(npm test:*), Bash(echo:*)"); + // The existing bar's "allow for this session" stays a session rule. + assert.deepEqual(posts, [{ permission_id: "permission", decision: "allow_always", scope: "session" }]); + const marked = gatewayEvents.find((value) => value.type === "tool_call" && value.arguments[args.TOOL_APPROVAL_PENDING_ARG] === true); + assert.deepEqual(marked.arguments[args.TOOL_APPROVAL_REMEMBER_RULES_ARG], ["Bash(npm test:*)", "Bash(echo:*)"]); +}); + +test("an explicit scope is posted with allow_always", async (t) => { + const { posts } = await runTurn(t, { + answer: ["approve_session", { scope: "project" }], + events: permissionEvents({ rules: ["Bash(npm test:*)"], default_scope: "project" }), + }); + assert.deepEqual(posts, [{ permission_id: "permission", decision: "allow_always", scope: "project" }]); +}); + +test("a denial posts its reason", async (t) => { + const { posts } = await runTurn(t, { + answer: ["deny", { reason: "run the unit tests only" }], + events: permissionEvents({ rules: ["Bash(npm test:*)"], default_scope: "project" }), + }); + assert.deepEqual(posts, [{ permission_id: "permission", decision: "reject", reason: "run the unit tests only" }]); +}); + +test("a backend without suggestions keeps the old session answer", async (t) => { + const { posts, listed } = await runTurn(t, { answer: ["approve_session", { scope: "project" }], events: permissionEvents(undefined) }); + assert.deepEqual(listed[0].rememberRules, []); + assert.equal(listed[0].sessionScope, "npm test"); + assert.deepEqual(posts, [{ permission_id: "permission", decision: "allow_always" }]); +}); + +test("permission.auto_resolved marks the call before or after it arrives", async (t) => { + const { gatewayEvents } = await runTurn(t, { + answer: ["approve"], + events: (event) => { + event("tool.call", { tool_call: { id: "after", name: "Bash", arguments: { command: "git status" } } }); + event("permission.auto_resolved", { tool_call_id: "after", tool: "Bash", command: "git status", source: "read_only" }); + event("permission.auto_resolved", { tool_call_id: "before", tool: "Bash", command: "npm test", source: "rule", rule: "Bash(npm test:*)", scope: "project" }); + event("tool.call", { tool_call: { id: "before", name: "Bash", arguments: { command: "npm test" } } }); + }, + }); + assert.deepEqual(autoResolved.getPermissionAutoResolved("after"), { source: "read_only" }); + assert.deepEqual(autoResolved.getPermissionAutoResolved("before"), { source: "rule", rule: "Bash(npm test:*)", scope: "project" }); + const last = (id) => gatewayEvents.filter((value) => value.type === "tool_call" && value.id === id).at(-1); + assert.deepEqual(args.readToolPermissionAutoResolved(last("after").arguments), { source: "read_only" }); + assert.deepEqual(args.readToolPermissionAutoResolved(last("before").arguments), { source: "rule", rule: "Bash(npm test:*)", scope: "project" }); +}); + +test("WebUI decisions carry scope and reason through the bridge, the Gateway RPC and the desktop queue", async () => { + const web = createTsModuleLoader({ rootDir: fileURLToPath(new URL("../../../agent-gateway/web/", import.meta.url)) }); + const bridge = web.loadModule("src/lib/chat/toolApprovalBridge.ts"); + const received = []; + bridge.registerToolApprovalDecisionHandler(async (...value) => { + received.push(value); + return { ok: true }; + }); + await bridge.submitToolApprovalDecision("call", "approve_session", "conv", { scope: "project" }); + await bridge.submitToolApprovalDecision("call", "deny", undefined, { reason: "no" }); + assert.deepEqual(received, [["call", "approve_session", "conv", { scope: "project" }], ["call", "deny", undefined, { reason: "no" }]]); + bridge.registerToolApprovalDecisionHandler(null); + + const read = (file) => readFileSync(new URL(file, import.meta.url), "utf8"); + assert.match(read("../../../agent-gateway/web/src/app/GatewayApp.tsx"), /buildToolApprovalRequestJson\(decision, options\)/); + for (const file of [ + "../../../agent-gateway/web/src/app/hooks/useGatewayChatPresentation.tsx", + "../../../agent-gateway/web/src/app/workbench/GatewayConversationPaneHost.tsx", + "../../src/pages/chat/components/PendingToolApprovalBar.tsx", + ]) { + assert.match(read(file), /onDecide=\{\(toolCallId, decision, options\)/, file); + } + const queue = read("../../src/pages/chat/queue/useChatTurnQueue.ts"); + assert.match(queue, /parseToolApprovalRequestJson\(request\.requestJson\)/); + assert.match(queue, /answerToolApproval\(itemId, parsed\.decision, \{\s*conversationId,\s*scope: parsed\.scope,\s*reason: parsed\.reason,?\s*\}\)/); +}); diff --git a/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs b/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs new file mode 100644 index 000000000..281ad5559 --- /dev/null +++ b/crates/agent-gui/test/chat/kbrain-approval-ui.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-2a UI: a K-brain approval that waits shows no countdown, and a tool row the +// backend allowed without asking says why. + +// Assertions compare strings and booleans, never DOM nodes: a failing assert would +// try to print a whole jsdom tree. +const icons = new Proxy({}, { get: () => () => null }); + +async function renderBar(pending) { + const env = await createDomTestEnv({ mocks: { + "@liveagent/ui/components/IconSet": icons, + "@liveagent/ui/components/ui/button": { Button: ({ children, size, variant, ...props }) => env.React.createElement("button", props, children) }, + "@liveagent/ui/components/ui/dropdown-menu": { + DropdownMenu: ({ children }) => env.React.createElement("div", null, children), + DropdownMenuTrigger: ({ render, children, ...props }) => env.React.cloneElement(render, props, children), + DropdownMenuContent: ({ children }) => env.React.createElement("div", { role: "menu" }, children), + DropdownMenuItem: ({ children, onSelect, disabled }) => env.React.createElement("button", { role: "menuitem", disabled, onClick: onSelect }, children), + }, + } }); + const { ToolApprovalBar } = env.loadModule("@liveagent/ui/components/chat/ToolApprovalBar.tsx"); + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + await env.act(async () => root.render(env.React.createElement(ToolApprovalBar, { + pending, + onDecide: async () => ({ ok: true }), + onDecideAll: async () => {}, + }))); + return { host, cleanup: async () => { await env.act(async () => root.unmount()); host.remove(); env.cleanup(); } }; +} + +test("an approval without a deadline shows no countdown", async () => { + const { host, cleanup } = await renderBar([{ toolCallId: "c1", toolName: "Bash", summary: "npm test", allowSession: true, sessionScope: "Bash(npm test:*)" }]); + try { + assert.equal(Boolean(host.querySelector('[role="timer"]')), false, "no countdown element"); + assert.doesNotMatch(host.textContent, /\d+:\d\d/); + } finally { + await cleanup(); + } +}); + +test("several waiting approvals still show the count without a countdown", async () => { + const { host, cleanup } = await renderBar([ + { toolCallId: "c1", toolName: "Bash", summary: "npm test" }, + { toolCallId: "c2", toolName: "Bash", summary: "npm run lint" }, + ]); + try { + assert.equal(Boolean(host.querySelector('[role="timer"]')), false, "no countdown element"); + assert.match(host.textContent, /1 \/ 2/); + assert.doesNotMatch(host.textContent, /\d+:\d\d/); + } finally { + await cleanup(); + } +}); + +test("an approval with a deadline still counts down", async () => { + const { host, cleanup } = await renderBar([{ toolCallId: "c1", toolName: "Bash", summary: "ls", deadlineAt: Date.now() + 90_000 }]); + try { + assert.match(host.querySelector('[role="timer"]').textContent, /^1:(29|30)$/); + } finally { + await cleanup(); + } +}); + +async function renderMarker(marker, labels) { + const env = await createDomTestEnv({ mocks: { + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "zh-CN", t: (key) => labels[key] ?? key }) }, + } }); + const { ToolPermissionMarker } = env.loadModule("@liveagent/ui/components/chat/assistant-bubble/ToolPermissionMarker.tsx"); + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + await env.act(async () => root.render(env.React.createElement(ToolPermissionMarker, { marker }))); + return { host, cleanup: async () => { await env.act(async () => root.unmount()); host.remove(); env.cleanup(); } }; +} + +test("a read-only call allowed without asking is marked", async () => { + const { host, cleanup } = await renderMarker({ source: "read_only" }, { "chat.toolPermission.readOnly": "只读,自动允许" }); + try { + const marker = host.querySelector("[data-permission-auto-resolved]"); + assert.equal(marker?.getAttribute("data-permission-auto-resolved"), "read_only"); + assert.equal(marker?.textContent, "只读,自动允许"); + } finally { + await cleanup(); + } +}); + +test("a call allowed by a remembered rule names the rule", async () => { + const { host, cleanup } = await renderMarker( + { source: "rule", rule: "Bash(npm test:*)", scope: "project" }, + { "chat.toolPermission.rule": "按规则允许", "chat.toolPermission.ruleTitle": "已记住的规则:{rule}" }, + ); + try { + const marker = host.querySelector("[data-permission-auto-resolved]"); + assert.equal(marker?.textContent, "按规则允许"); + assert.equal(marker?.getAttribute("title"), "已记住的规则:Bash(npm test:*)"); + } finally { + await cleanup(); + } +}); + +test("calls the backend asked about carry no marker", async () => { + const { host, cleanup } = await renderMarker(null, {}); + try { + assert.equal(host.innerHTML, ""); + } finally { + await cleanup(); + } +}); + +// The real tool row, in each of its header layouts: a file link (Read, Write, Edit), +// a compact chip and the full row. +async function renderToolRow(item, props = {}) { + const icons = new Proxy({}, { get: () => () => null }); + const iconPath = fileURLToPath(new URL("../../../agent-ui/src/components/IconSet.tsx", import.meta.url)); + const env = await createDomTestEnv({ mocks: { + "@liveagent/ui/components/IconSet": icons, + [iconPath]: icons, + "@liveagent/ui/i18n/index": { useLocale: () => ({ locale: "en-US", t: (key) => key }) }, + "@liveagent/ui/components/Markdown": { Markdown: () => null }, + "@liveagent/ui/components/chat/EditDiffView": { EditDiffView: () => null }, + "@liveagent/ui/components/chat/FileToolArgs": { FileToolArgsDisplay: () => null }, + "@liveagent/ui/components/chat/AskUserQuestionCard": { AskUserQuestionCard: () => null }, + "@liveagent/ui/components/chat/PlanModeCard": { PlanModeCard: () => null }, + "@liveagent/adapters/assistantBubble": { + readAskUserQuestionDeadline: () => undefined, + usePlanDecisionState: () => ({ pending: false, approved: false }), + retainRunningToolContent: false, + submitAskUserQuestionAnswers() {}, + submitPlanDecision() {}, + usePendingToolApproval: () => false, + usePermissionAutoResolved: (id) => (id === "marked" ? { source: "read_only" } : null), + deferLargeToolImages: false, + }, + } }); + const { MemoToolCallItem } = env.loadModule("@liveagent/ui/components/chat/assistant-bubble/ToolCallItem.tsx"); + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + await env.act(async () => root.render(env.React.createElement(MemoToolCallItem, { item, ...props }))); + const marker = host.querySelector("[data-permission-auto-resolved]")?.getAttribute("data-permission-auto-resolved") ?? null; + await env.act(async () => root.unmount()); + host.remove(); + env.cleanup(); + return marker; +} + +const toolItem = (id, name, args) => ({ + toolCall: { type: "toolCall", id, name, arguments: args }, + toolResult: { role: "toolResult", toolCallId: id, toolName: name, content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1 }, +}); + +for (const [layout, name, args, props] of [ + ["file link", "Read", { path: "README.md" }, {}], + ["full row", "mcp__fs__list", { path: "." }, {}], + ["compact chip", "mcp__fs__list", { path: "." }, { compactChip: true }], +]) { + test(`the ${layout} row shows why the backend allowed it`, async () => { + assert.equal(await renderToolRow(toolItem("marked", name, args), props), "read_only"); + assert.equal(await renderToolRow(toolItem("asked", name, args), props), null); + }); +} + +test("both languages name the markers", () => { + for (const key of ["chat.toolPermission.readOnly", "chat.toolPermission.rule", "chat.toolPermission.ruleTitle"]) { + for (const file of ["zhCNCommon.ts", "enUSCommon.ts"]) { + const text = readFileSync(new URL(`../../../agent-ui/src/i18n/translations/${file}`, import.meta.url), "utf8"); + assert.ok(text.includes(`"${key}"`), `${key} in ${file}`); + } + } +}); + +test("both hosts read the marker: the desktop from its store, WebUI from the synced arguments", () => { + const react = { useSyncExternalStore: (_subscribe, get) => get() }; + const gui = createTsModuleLoader({ mocks: { react } }); + const store = gui.loadModule("src/lib/tools/permissionAutoResolved.ts"); + store.recordPermissionAutoResolved("gui-call", { source: "rule", rule: "Bash(ls:*)" }); + const guiAdapter = gui.loadModule("src/agent-ui-adapters/assistantBubble.ts"); + assert.deepEqual(guiAdapter.usePermissionAutoResolved("gui-call", {}), { source: "rule", rule: "Bash(ls:*)" }); + assert.equal(guiAdapter.usePermissionAutoResolved("other", {}), null); + + const web = createTsModuleLoader({ rootDir: fileURLToPath(new URL("../../../agent-gateway/web/", import.meta.url)), mocks: { react } }); + const webAdapter = web.loadModule("src/agent-ui-adapters/assistantBubble.ts"); + assert.deepEqual(webAdapter.usePermissionAutoResolved("x", { __toolPermissionAutoResolved: { source: "read_only" } }), { source: "read_only" }); + assert.equal(webAdapter.usePermissionAutoResolved("x", { command: "ls" }), null); +}); diff --git a/crates/agent-gui/test/chat/review-la2a-approval-r2.test.mjs b/crates/agent-gui/test/chat/review-la2a-approval-r2.test.mjs new file mode 100644 index 000000000..9ddcc9e86 --- /dev/null +++ b/crates/agent-gui/test/chat/review-la2a-approval-r2.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-2a independent review, round 2 (after the clamp and 400-retry fixes): regressions driven through the real K-brain turn +// (runAgentConversationTurn -> runKBrainTurn -> toolApproval -> gatewayToolPreview) +// against an http.createServer K-brain fixture. Assertions compare strings, +// numbers and booleans only. + +const loader = createTsModuleLoader(); +const { runAgentConversationTurn } = loader.loadModule("src/pages/chat/turns/runAgentConversationTurn.ts"); +const { createConversationStateFromContext } = loader.loadModule("src/lib/chat/conversation/conversationState.ts"); +const approval = loader.loadModule("src/lib/tools/toolApproval.ts"); +const args = loader.loadModule("../agent-ui/src/lib/chat/toolApprovalArgs.ts"); +const { setKBrainRuntimeConnection, clearKBrainRuntimeConnection } = loader.loadModule("src/lib/kbrain/runtimeConnection.ts"); + +// Same limit gatewayToolPreview applies to __toolApprovalSummary ("不能不设上限"). +const SYNCED_APPROVAL_TEXT_MAX = 2000; + +async function runTurn(t, { answers, events, onPermissionPost, answerFilter = () => true }) { + const conversationId = `conv-${Math.random().toString(36).slice(2)}`; + const posts = []; + const urls = []; + let stream; + let sequence = 0; + const event = (type, payload) => stream.write(`data: ${JSON.stringify({ + version: "kbrain.agent.v1", seq: ++sequence, conversation_id: "session", run_id: "run", type, + created_at: new Date().toISOString(), payload, + })}\n\n`); + const finish = () => { + event("assistant.message.created", { role: "assistant", content: [{ type: "text", text: "done" }] }); + event("run.completed", { state: "completed" }); + stream.end(); + }; + const server = http.createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + urls.push(`${request.method} ${request.url}`); + const json = (value, code = 200) => { + response.writeHead(code, { "Content-Type": "application/json" }); + response.end(JSON.stringify(value)); + }; + if (request.url === "/v1/sessions") return json({ id: "session" }, 201); + if (request.url === "/v1/sessions/session/runs") return json({ version: "kbrain.agent.v1", conversation_id: "session", run_id: "run", accepted_seq: 1 }, 202); + if (request.url === "/v1/sessions/session" || request.url.startsWith("/v1/sessions/session/history")) { + const session = { id: "session", revision: "r", title: "t", model: { provider: "fixture", model: "model" }, created_at: "2026-11-01T00:00:00Z", updated_at: "2026-11-01T00:00:01Z", message_count: 0, last_seq: sequence, messages: [] }; + return json(request.url.includes("/history") ? { session, revision: "r", oldest_offset: 0, has_more_before: false, total_message_count: 0, active_messages: [] } : session); + } + if (request.url.startsWith("/v1/sessions/session/events")) { + stream = response; + response.writeHead(200, { "Content-Type": "text/event-stream" }); + sequence = Number(new URL(request.url, "http://localhost").searchParams.get("after_seq") ?? 0); + events(event); + return; + } + if (request.url.startsWith("/v1/sessions/session/permissions/")) { + const decision = JSON.parse(body).decision; + posts.push(decision); + const handled = onPermissionPost?.(decision, posts.length, json, { event, finish }); + if (handled) return; + json({ ok: true }); + event("permission.resolved", { permission_id: decision.permission_id }); + finish(); + return; + } + json({ error: `Unexpected path ${request.url}` }, 404); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const originalStorage = globalThis.localStorage; + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + key: (index) => Array.from(storage.keys())[index] ?? null, + get length() { return storage.size; }, + }; + setKBrainRuntimeConnection({ baseUrl: origin, token: "fixture-token", protocolVersion: "kbrain.agent.v1" }); + t.after(() => { + clearKBrainRuntimeConnection(); + if (originalStorage === undefined) delete globalThis.localStorage; + else globalThis.localStorage = originalStorage; + server.closeAllConnections(); + server.close(); + }); + const listed = []; + const answered = new Set(); + const dispose = approval.subscribeToolApprovalsForConversation(conversationId, () => queueMicrotask(() => { + for (const pending of approval.listPendingToolApprovalsForConversation(conversationId)) { + const key = `${pending.toolCallId}#${listed.length}`; + if (answered.has(key) || !answerFilter(pending)) continue; + answered.add(key); + listed.push(pending); + const entry = answers[Math.min(listed.length - 1, answers.length - 1)]; + if (typeof entry === "function") { entry({ pending, controller, index: listed.length - 1 }); continue; } + const [decision, options] = entry; + approval.answerToolApproval(pending.toolCallId, decision, { conversationId, ...options }); + } + })); + t.after(dispose); + const context = { systemPrompt: "system", messages: [{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }] }; + let state = createConversationStateFromContext(context); + let rounds = []; + const gatewayEvents = []; + const errors = []; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + t.after(() => clearTimeout(timeout)); + await runAgentConversationTurn({ + providerId: "codex", model: "model", selectedModel: { customProviderId: "fixture", model: "model" }, + runtime: { backend: "kbrain", baseUrl: "https://must-not-be-called.invalid", apiKey: "unused" }, sessionId: "frontend-session", conversationId, + trajectoryMessageId: "message", conversationCwd: "/workspace", effectiveWorkdir: "/workspace", createdAt: 1, fallbackTitle: "t", titlePromise: null, + transcriptStore: {}, cancellation: { userStop: controller }, + gatewayBridgeEvents: { queueToken() {}, queueEvent(value) { gatewayEvents.push(value); }, queueToolStatus() {}, emitError(message) { errors.push(String(message)); } }, + hookLifecycle: Object.fromEntries(["startAgent", "startTurn", "ensureMessageEnded", "endTurn", "endAgent"].map((key) => [key, () => {}])), + buildPreparedContext: () => context, getNextConversationState: () => state, + applyConversationState: (value) => { state = value; }, + batchLiveRoundsUpdate: (update) => { rounds = update(rounds); }, + updateToolStatus() {}, updateGatewayBridgeToolStatus() {}, freezeGatewayFinalProjection() {}, settleLiveTranscript() {}, + persistConversationWithHistorySync: async () => true, + }); + return { posts, listed, gatewayEvents, errors, urls, finish, event }; +} + +const allowAlwaysOptions = [ + { id: "allow_once", kind: "allow_once" }, + { id: "reject", kind: "reject_once" }, + { id: "allow_always", label: "Allow and remember", kind: "allow_always" }, +]; + +const { buildGatewayToolCallPreviewArguments } = loader.loadModule("src/pages/chat/turns/gatewayToolPreview.ts"); + +const copiesOf = (gatewayEvents, id) => gatewayEvents.filter((value) => value.type === "tool_call" && value.id === id); +const hasMarker = (value) => value.arguments[args.TOOL_APPROVAL_PENDING_ARG] === true; + +// First subscribe plays the prompt; a later subscribe (waitForCancelledRun after a stop) +// sees the run cancelled, as K-brain would publish it. +function promptEvents(extra = {}) { + let subscribes = 0; + return (event) => { + subscribes += 1; + if (subscribes > 1) { + event("run.cancelled", { state: "cancelled" }); + return; + } + event("tool.call", { tool_call: { id: "call", name: "Bash", arguments: { command: "npm test" } } }); + event("permission.requested", { + permission_id: "permission", tool: "Bash", command: "npm test", rule: "npm test", shell: "bash", tool_call_id: "call", + options: allowAlwaysOptions, suggestion: { rules: ["Bash(npm test:*)"], default_scope: "session" }, + ...extra, + }); + }; +} + +test("verify: a user stop while the re-asked prompt is shown ends the loop without asking again", async (t) => { + const { posts, listed, gatewayEvents } = await runTurn(t, { + answers: [["approve_session", { scope: "project" }], ({ controller }) => controller.abort()], + events: promptEvents(), + onPermissionPost: (decision, count, json) => { + if (count === 1) { + json({ error: "project rules are not available on this backend" }, 400); + return true; + } + json({ ok: true }); + return true; + }, + }); + assert.equal(listed.length, 2, `prompt shown ${listed.length} times`); + assert.equal(approval.hasPendingToolApproval("call"), false, "nothing left in the desktop store"); + // At most one reject may follow the stop (pre-existing: the stop races the answer); no scoped retry. + assert.equal(posts.filter((post) => post.decision !== "reject").length, 1, JSON.stringify(posts)); + assert.equal(hasMarker(copiesOf(gatewayEvents, "call").at(-1)), false, "WebUI copy cleared"); +}); + +test("verify: permission.resolved while the re-asked prompt is shown clears it; Gateway re-pushed the marker for the retry", async (t) => { + const { posts, listed, gatewayEvents } = await runTurn(t, { + answers: [["approve_session", { scope: "project" }], () => {}], + events: promptEvents(), + onPermissionPost: (decision, count, json, { event, finish }) => { + json({ error: "project rules are not available on this backend" }, 400); + setTimeout(() => { + event("permission.resolved", { permission_id: "permission", decision: "allow_once" }); + setTimeout(finish, 30); + }, 50); + return true; + }, + }); + assert.equal(posts.length, 1); + assert.equal(listed.length, 2, "re-asked once"); + assert.equal(listed[1].sessionScope.length <= 300, true); + assert.equal(approval.hasPendingToolApproval("call"), false); + const copies = copiesOf(gatewayEvents, "call"); + const markerFlips = copies.map((value) => (hasMarker(value) ? "P" : "-")).join(""); + // tool.call (-), first ask (P), settle (-), retry ask (P), released (-) + assert.equal(/P-+P-+$/.test(markerFlips), true, `marker sequence ${markerFlips}`); +}); + +test("verify: run.completed while the re-asked prompt is shown clears it", async (t) => { + const { posts, gatewayEvents } = await runTurn(t, { + answers: [["approve_session", { scope: "project" }], () => {}], + events: promptEvents(), + onPermissionPost: (decision, count, json, { finish }) => { + json({ error: "refused" }, 400); + setTimeout(finish, 50); + return true; + }, + }); + assert.equal(posts.length, 1); + assert.equal(approval.hasPendingToolApproval("call"), false); + assert.equal(hasMarker(copiesOf(gatewayEvents, "call").at(-1)), false); +}); + +test("verify: after PERMISSION_ANSWER_RETRIES refusals the run fails as before", async (t) => { + const { posts, urls } = await runTurn(t, { + answers: [["approve_session", { scope: "project" }]], + events: promptEvents(), + onPermissionPost: (decision, count, json) => { + json({ error: "refused" }, 400); + return true; + }, + }); + assert.equal(posts.length, 4, `posted ${posts.length} times`); + assert.equal(urls.some((url) => url.includes("/cancel")), true); +}); + +// clampApprovalRules promises a bound on the *serialized* length, but measures each rule +// by its raw length. Rules with quotes or backslashes (python -c "...", Windows paths) +// grow when JSON-encoded. +test("remember rules synced to WebUI stay within 2000 serialized chars when they contain quotes", () => { + const rule = `Bash(python -c "${'print("x");'.repeat(160)}")`; // ~1940 raw chars, many quotes + const toolCallId = `clamp-${Math.random().toString(36).slice(2)}`; + const controller = new AbortController(); + void approval.requestToolApproval({ + toolCallId, toolName: "Bash", conversationId: "clamp-conv", allowSession: true, + rememberRules: [rule], timeoutMs: null, signal: controller.signal, + }); + try { + const synced = buildGatewayToolCallPreviewArguments({ id: toolCallId, name: "Bash", arguments: { command: "x" } }); + const serialized = JSON.stringify(synced[args.TOOL_APPROVAL_REMEMBER_RULES_ARG]).length; + assert.equal(serialized <= SYNCED_APPROVAL_TEXT_MAX, true, `serialized rules are ${serialized} chars (raw rule ${rule.length})`); + } finally { + controller.abort(); + } +}); + +test("verify: clampApprovalRules boundaries for plain rules", () => { + const max = SYNCED_APPROVAL_TEXT_MAX; + const size = (rules) => JSON.stringify(rules).length; + assert.equal(size(args.clampApprovalRules([], max)), 2); + // The bound is the serialized length: ["…"] adds 4 characters around the rule. + const fits = "a".repeat(max - 4); + const kept = args.clampApprovalRules([fits], max); + assert.equal(kept.length === 1 && kept[0] === fits, true, "a rule that fits is kept whole"); + assert.equal(size(kept) <= max, true); + const over = args.clampApprovalRules(["a".repeat(max - 3)], max); + assert.equal(over.length === 1 && over[0].endsWith("…") && size(over) <= max, true, `one over: ${size(over)}`); + const many = args.clampApprovalRules(["a".repeat(900), "b".repeat(900), "c".repeat(900), "d"], max); + assert.equal(many.length, 3, "stops after the truncated rule"); + assert.equal(many[2].endsWith("…"), true); + assert.equal(size(many) <= max, true, `accumulated ${size(many)}`); + assert.equal(args.clampApprovalRules(["x"], 5).length, 1, "[\"x\"] is exactly 5 characters"); + assert.equal(args.clampApprovalRules(["xy"], 5).length, 0, "no room for a cut rule yields an empty list"); +}); diff --git a/crates/agent-gui/test/chat/review-la2a-approval.test.mjs b/crates/agent-gui/test/chat/review-la2a-approval.test.mjs new file mode 100644 index 000000000..4d5d94484 --- /dev/null +++ b/crates/agent-gui/test/chat/review-la2a-approval.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-2a independent review: regressions driven through the real K-brain turn +// (runAgentConversationTurn -> runKBrainTurn -> toolApproval -> gatewayToolPreview) +// against an http.createServer K-brain fixture. Assertions compare strings, +// numbers and booleans only. + +const loader = createTsModuleLoader(); +const { runAgentConversationTurn } = loader.loadModule("src/pages/chat/turns/runAgentConversationTurn.ts"); +const { createConversationStateFromContext } = loader.loadModule("src/lib/chat/conversation/conversationState.ts"); +const approval = loader.loadModule("src/lib/tools/toolApproval.ts"); +const args = loader.loadModule("../agent-ui/src/lib/chat/toolApprovalArgs.ts"); +const { setKBrainRuntimeConnection, clearKBrainRuntimeConnection } = loader.loadModule("src/lib/kbrain/runtimeConnection.ts"); + +// Same limit gatewayToolPreview applies to __toolApprovalSummary ("不能不设上限"). +const SYNCED_APPROVAL_TEXT_MAX = 2000; + +async function runTurn(t, { answers, events, onPermissionPost, answerFilter = () => true }) { + const conversationId = `conv-${Math.random().toString(36).slice(2)}`; + const posts = []; + const urls = []; + let stream; + let sequence = 0; + const event = (type, payload) => stream.write(`data: ${JSON.stringify({ + version: "kbrain.agent.v1", seq: ++sequence, conversation_id: "session", run_id: "run", type, + created_at: new Date().toISOString(), payload, + })}\n\n`); + const finish = () => { + event("assistant.message.created", { role: "assistant", content: [{ type: "text", text: "done" }] }); + event("run.completed", { state: "completed" }); + stream.end(); + }; + const server = http.createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + urls.push(`${request.method} ${request.url}`); + const json = (value, code = 200) => { + response.writeHead(code, { "Content-Type": "application/json" }); + response.end(JSON.stringify(value)); + }; + if (request.url === "/v1/sessions") return json({ id: "session" }, 201); + if (request.url === "/v1/sessions/session/runs") return json({ version: "kbrain.agent.v1", conversation_id: "session", run_id: "run", accepted_seq: 1 }, 202); + if (request.url === "/v1/sessions/session" || request.url.startsWith("/v1/sessions/session/history")) { + const session = { id: "session", revision: "r", title: "t", model: { provider: "fixture", model: "model" }, created_at: "2026-11-01T00:00:00Z", updated_at: "2026-11-01T00:00:01Z", message_count: 0, last_seq: sequence, messages: [] }; + return json(request.url.includes("/history") ? { session, revision: "r", oldest_offset: 0, has_more_before: false, total_message_count: 0, active_messages: [] } : session); + } + if (request.url.startsWith("/v1/sessions/session/events")) { + stream = response; + response.writeHead(200, { "Content-Type": "text/event-stream" }); + sequence = Number(new URL(request.url, "http://localhost").searchParams.get("after_seq") ?? 0); + events(event); + return; + } + if (request.url.startsWith("/v1/sessions/session/permissions/")) { + const decision = JSON.parse(body).decision; + posts.push(decision); + const handled = onPermissionPost?.(decision, posts.length, json, { event, finish }); + if (handled) return; + json({ ok: true }); + event("permission.resolved", { permission_id: decision.permission_id }); + finish(); + return; + } + json({ error: `Unexpected path ${request.url}` }, 404); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const originalStorage = globalThis.localStorage; + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + key: (index) => Array.from(storage.keys())[index] ?? null, + get length() { return storage.size; }, + }; + setKBrainRuntimeConnection({ baseUrl: origin, token: "fixture-token", protocolVersion: "kbrain.agent.v1" }); + t.after(() => { + clearKBrainRuntimeConnection(); + if (originalStorage === undefined) delete globalThis.localStorage; + else globalThis.localStorage = originalStorage; + server.closeAllConnections(); + server.close(); + }); + const listed = []; + const answered = new Set(); + const dispose = approval.subscribeToolApprovalsForConversation(conversationId, () => queueMicrotask(() => { + for (const pending of approval.listPendingToolApprovalsForConversation(conversationId)) { + const key = `${pending.toolCallId}#${listed.length}`; + if (answered.has(key) || !answerFilter(pending)) continue; + answered.add(key); + listed.push(pending); + const [decision, options] = answers[Math.min(listed.length - 1, answers.length - 1)]; + approval.answerToolApproval(pending.toolCallId, decision, { conversationId, ...options }); + } + })); + t.after(dispose); + const context = { systemPrompt: "system", messages: [{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }] }; + let state = createConversationStateFromContext(context); + let rounds = []; + const gatewayEvents = []; + const errors = []; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + t.after(() => clearTimeout(timeout)); + await runAgentConversationTurn({ + providerId: "codex", model: "model", selectedModel: { customProviderId: "fixture", model: "model" }, + runtime: { backend: "kbrain", baseUrl: "https://must-not-be-called.invalid", apiKey: "unused" }, sessionId: "frontend-session", conversationId, + trajectoryMessageId: "message", conversationCwd: "/workspace", effectiveWorkdir: "/workspace", createdAt: 1, fallbackTitle: "t", titlePromise: null, + transcriptStore: {}, cancellation: { userStop: controller }, + gatewayBridgeEvents: { queueToken() {}, queueEvent(value) { gatewayEvents.push(value); }, queueToolStatus() {}, emitError(message) { errors.push(String(message)); } }, + hookLifecycle: Object.fromEntries(["startAgent", "startTurn", "ensureMessageEnded", "endTurn", "endAgent"].map((key) => [key, () => {}])), + buildPreparedContext: () => context, getNextConversationState: () => state, + applyConversationState: (value) => { state = value; }, + batchLiveRoundsUpdate: (update) => { rounds = update(rounds); }, + updateToolStatus() {}, updateGatewayBridgeToolStatus() {}, freezeGatewayFinalProjection() {}, settleLiveTranscript() {}, + persistConversationWithHistorySync: async () => true, + }); + return { posts, listed, gatewayEvents, errors, urls, finish, event }; +} + +const allowAlwaysOptions = [ + { id: "allow_once", kind: "allow_once" }, + { id: "reject", kind: "reject_once" }, + { id: "allow_always", label: "Allow and remember", kind: "allow_always" }, +]; + +// K-brain permissions/suggest.go: an interpreter segment (python/node/bash -c ...) is +// suggested as an exact rule holding the whole segment, so a long inline script makes a +// rule as long as the command itself. +test("rules synced to WebUI are bounded like the approval summary", async (t) => { + const script = "print(1);".repeat(1200); // 10800 chars + const command = `python -c '${script}'`; + const rule = `Bash(${command})`; + const { gatewayEvents } = await runTurn(t, { + answers: [["approve"]], + events: (event) => { + event("tool.call", { tool_call: { id: "call", name: "Bash", arguments: { command } } }); + event("permission.requested", { + permission_id: "permission", tool: "Bash", command, rule: "python", shell: "bash", tool_call_id: "call", + options: allowAlwaysOptions, suggestion: { rules: [rule], default_scope: "project" }, + }); + }, + }); + const marked = gatewayEvents.find((value) => value.type === "tool_call" && value.arguments[args.TOOL_APPROVAL_PENDING_ARG] === true); + assert.equal(Boolean(marked), true, "a pending Gateway copy was pushed"); + const summary = String(marked.arguments[args.TOOL_APPROVAL_SUMMARY_ARG]); + const sessionScope = String(marked.arguments[args.TOOL_APPROVAL_SESSION_SCOPE_ARG] ?? ""); + const rulesChars = JSON.stringify(marked.arguments[args.TOOL_APPROVAL_REMEMBER_RULES_ARG] ?? []).length; + assert.equal(summary.length <= SYNCED_APPROVAL_TEXT_MAX, true, `summary is clamped (${summary.length})`); + assert.equal(sessionScope.length <= SYNCED_APPROVAL_TEXT_MAX, true, `__toolApprovalSessionScope is ${sessionScope.length} chars`); + assert.equal(rulesChars <= SYNCED_APPROVAL_TEXT_MAX, true, `__toolApprovalRememberRules is ${rulesChars} chars`); +}); + +// K-brain server.go permission(): when storing the suggestion fails (a refused scope such +// as "project rules are not available on this backend", or an unreadable rules file) it +// answers 400 and puts the prompt back: "A refused scope or a failed write leaves the +// prompt waiting." The run is still alive and the prompt can be answered again. +test("a refused remember scope (400, prompt kept waiting) does not kill the run", async (t) => { + const { posts, errors, urls, listed } = await runTurn(t, { + answers: [["approve_session", { scope: "project" }], ["approve"]], + events: (event) => { + event("tool.call", { tool_call: { id: "call", name: "Bash", arguments: { command: "npm test" } } }); + event("permission.requested", { + permission_id: "permission", tool: "Bash", command: "npm test", rule: "npm test", shell: "bash", tool_call_id: "call", + options: allowAlwaysOptions, suggestion: { rules: ["Bash(npm test:*)"], default_scope: "session" }, + }); + }, + onPermissionPost: (decision, count, json) => { + if (count === 1) { + json({ error: "project rules are not available on this backend" }, 400); + return true; + } + return false; + }, + }); + assert.equal(posts[0]?.scope, "project", "the scoped answer was sent first"); + const cancelled = urls.some((url) => url.includes("/cancel")); + assert.equal(cancelled, false, `the client cancelled the K-brain run: ${urls.filter((url) => url.includes("/cancel")).join(", ")}`); + assert.equal(errors.length, 0, `turn failed: ${errors.join(" | ")}`); + assert.equal(listed.length >= 2, true, `the still-waiting prompt was offered again (listed ${listed.length})`); +}); + +// --- Verification (expected to pass): without a deadline the approval still settles. --- + +const lastCopy = (gatewayEvents, id) => gatewayEvents.filter((value) => value.type === "tool_call" && value.id === id).at(-1); + +test("verify: a prompt K-brain releases by a new rule (permission.resolved) leaves bar and WebUI", async (t) => { + const { posts, gatewayEvents, errors } = await runTurn(t, { + answers: [["approve_session"]], + answerFilter: (pending) => pending.toolCallId === "a", + events: (event) => { + event("tool.call", { tool_call: { id: "a", name: "Bash", arguments: { command: "npm test" } } }); + event("tool.call", { tool_call: { id: "b", name: "Bash", arguments: { command: "npm test -- x" } } }); + for (const [pid, call] of [["pa", "a"], ["pb", "b"]]) { + event("permission.requested", { + permission_id: pid, tool: "Bash", command: "npm test", rule: "npm test", shell: "bash", tool_call_id: call, + options: allowAlwaysOptions, suggestion: { rules: ["Bash(npm test:*)"], default_scope: "project" }, + }); + } + }, + onPermissionPost: (decision, count, json, { event, finish }) => { + json({ ok: true }); + // settleCoveredPermissions releases pb; waitPermission publishes permission.resolved for both. + event("permission.resolved", { permission_id: "pa", decision: "allow_always" }); + event("permission.resolved", { permission_id: "pb", decision: "allow_once" }); + setTimeout(finish, 50); + return true; + }, + }); + assert.equal(errors.length, 0, errors.join(" | ")); + assert.equal(posts.length, 1, "only the answered prompt was posted"); + assert.equal(posts[0].scope, "session"); + assert.equal(approval.hasPendingToolApproval("a"), false); + assert.equal(approval.hasPendingToolApproval("b"), false, "released prompt left the desktop store"); + assert.equal(lastCopy(gatewayEvents, "b").arguments[args.TOOL_APPROVAL_PENDING_ARG] === true, false, "WebUI got a copy without the pending marker"); +}); + +test("verify: run.failed while a prompt waits settles it and re-pushes the call without the marker", async (t) => { + const { gatewayEvents } = await runTurn(t, { + answers: [["approve"]], + answerFilter: () => false, + events: (event) => { + event("tool.call", { tool_call: { id: "c", name: "Bash", arguments: { command: "npm test" } } }); + event("permission.requested", { + permission_id: "pc", tool: "Bash", command: "npm test", rule: "npm test", shell: "bash", tool_call_id: "c", + options: allowAlwaysOptions, suggestion: { rules: ["Bash(npm test:*)"], default_scope: "project" }, + }); + setTimeout(() => event("run.failed", { state: "failed", error: "boom" }), 50); + }, + }); + assert.equal(approval.hasPendingToolApproval("c"), false); + const copies = gatewayEvents.filter((value) => value.type === "tool_call" && value.id === "c"); + assert.equal(copies.some((value) => value.arguments[args.TOOL_APPROVAL_PENDING_ARG] === true), true, "marker was pushed while waiting"); + assert.equal(copies.at(-1).arguments[args.TOOL_APPROVAL_PENDING_ARG] === true, false, "last copy has no marker"); +}); diff --git a/crates/agent-gui/test/chat/tool-argument-display.test.mjs b/crates/agent-gui/test/chat/tool-argument-display.test.mjs index 7129a2c37..ae80e748f 100644 --- a/crates/agent-gui/test/chat/tool-argument-display.test.mjs +++ b/crates/agent-gui/test/chat/tool-argument-display.test.mjs @@ -164,6 +164,9 @@ function createToolCallItemRenderer() { usePendingToolApproval() { return null; }, + usePermissionAutoResolved() { + return null; + }, }, "@liveagent/ui/components/chat/AskUserQuestionCard": { AskUserQuestionCard: NullComponent, diff --git a/crates/agent-gui/test/providers/kbrain-permission-answer.test.mjs b/crates/agent-gui/test/providers/kbrain-permission-answer.test.mjs new file mode 100644 index 000000000..d5fb392a9 --- /dev/null +++ b/crates/agent-gui/test/providers/kbrain-permission-answer.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-2a: the K-brain turn reads suggestion/shell from permission.requested, posts the +// remember scope and a denial reason, and reports permission.auto_resolved. + +const loader = createTsModuleLoader(); +const { runKBrainTurn } = loader.loadModule("src/lib/kbrain/turn.ts"); + +const model = { provider: "fixture", model: "fixture-model" }; +const storage = new Map(); +globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), +}; +const baseParams = { + conversationId: "conversation-1", + sessionId: "host-session-1", + cwd: "/tmp/workspace", + model, + prompt: "hello", + context: { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + signal: new AbortController().signal, + onTextDelta() {}, + onThinkingDelta() {}, + onToolCall() {}, + onToolResult() {}, +}; + +function event(seq, type, payload = {}) { + return `data: ${JSON.stringify({ + version: "kbrain.agent.v1", + seq, + conversation_id: "backend-session-1", + run_id: "run-1", + type, + created_at: "2026-11-01T00:00:00Z", + payload, + })}\n\n`; +} + +function json(value, status = 200) { + return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); +} + +// The event stream stays open until the permission answer is posted, as K-brain's +// does while the tool waits. +function makeFetch(events) { + const posts = []; + let answered; + const answeredPromise = new Promise((resolve) => { + answered = resolve; + }); + const fetch = async (url, init = {}) => { + const parsed = new URL(String(url)); + if (parsed.pathname === "/v1/sessions" && init.method === "POST") { + return json({ id: "backend-session-1", model, messages: [], last_seq: 0 }); + } + if (parsed.pathname.endsWith("/runs")) { + return json({ version: "kbrain.agent.v1", conversation_id: "backend-session-1", run_id: "run-1", accepted_seq: 1 }, 202); + } + if (parsed.pathname.endsWith("/events")) { + const body = new ReadableStream({ + async start(controller) { + const encode = (text) => controller.enqueue(new TextEncoder().encode(text)); + encode(events.join("")); + if (events.some((text) => text.includes('"permission.requested"'))) await answeredPromise; + encode(event(99, "run.completed")); + controller.close(); + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + } + if (parsed.pathname.includes("/permissions/")) { + posts.push({ path: parsed.pathname, body: JSON.parse(init.body) }); + answered(); + return json({ ok: true }); + } + if (parsed.pathname.includes("/cancel")) return json({ ok: true }); + throw new Error(`unexpected K-brain URL ${parsed.pathname}`); + }; + return { fetch, posts }; +} + +const requested = (extra = {}) => + event(2, "permission.requested", { + permission_id: "perm-1", + tool: "Bash", + command: "npm test && echo done", + rule: "npm test", + shell: "pwsh", + tool_call_id: "call-1", + options: [ + { id: "allow_once", kind: "allow_once" }, + { id: "allow_always", label: "Allow and remember", kind: "allow_always" }, + { id: "reject", kind: "reject_once" }, + ], + suggestion: { rules: ["Bash(npm test:*)"], default_scope: "project" }, + ...extra, + }); + +test("the request exposes shell and suggestion, and allow_always posts its scope", async () => { + storage.clear(); + const { fetch, posts } = makeFetch([requested()]); + const seen = []; + await runKBrainTurn({ + ...baseParams, + fetch, + onPermissionRequest: async (request) => { + seen.push(request); + return { decision: "allow_always", scope: "project" }; + }, + }); + assert.equal(seen[0].shell, "pwsh"); + assert.deepEqual(seen[0].suggestion, { rules: ["Bash(npm test:*)"], default_scope: "project" }); + assert.deepEqual(posts[0].body.decision, { permission_id: "perm-1", decision: "allow_always", scope: "project" }); +}); + +test("a rejection posts the reason for the model", async () => { + storage.clear(); + const { fetch, posts } = makeFetch([requested()]); + await runKBrainTurn({ + ...baseParams, + fetch, + onPermissionRequest: async () => ({ decision: "reject", reason: "change utils.ts instead" }), + }); + assert.deepEqual(posts[0].body.decision, { permission_id: "perm-1", decision: "reject", reason: "change utils.ts instead" }); +}); + +test("a plain string answer still posts only the decision", async () => { + storage.clear(); + const { fetch, posts } = makeFetch([requested({ suggestion: undefined })]); + await runKBrainTurn({ ...baseParams, fetch, onPermissionRequest: async () => "allow_always" }); + assert.deepEqual(posts[0].body.decision, { permission_id: "perm-1", decision: "allow_always" }); +}); + +test("scope and reason only travel with the decision they belong to", async () => { + storage.clear(); + const { fetch, posts } = makeFetch([requested()]); + await runKBrainTurn({ + ...baseParams, + fetch, + onPermissionRequest: async () => ({ decision: "allow_once", scope: "user", reason: "ignored" }), + }); + assert.deepEqual(posts[0].body.decision, { permission_id: "perm-1", decision: "allow_once" }); +}); + +test("permission.auto_resolved reaches the turn callback", async () => { + storage.clear(); + const { fetch } = makeFetch([ + event(2, "tool.call", { tool_call: { id: "call-2", name: "Bash", arguments: { command: "git status" } } }), + event(3, "permission.auto_resolved", { tool_call_id: "call-2", tool: "Bash", command: "git status", source: "read_only" }), + event(4, "permission.auto_resolved", { tool_call_id: "call-3", tool: "Bash", command: "npm test", source: "rule", rule: "Bash(npm test:*)", scope: "project" }), + event(5, "permission.auto_resolved", { tool_call_id: "", tool: "Bash", source: "rule" }), + ]); + const resolved = []; + await runKBrainTurn({ ...baseParams, fetch, onPermissionAutoResolved: (value) => resolved.push(value) }); + assert.deepEqual(resolved, [ + { toolCallId: "call-2", source: "read_only" }, + { toolCallId: "call-3", source: "rule", rule: "Bash(npm test:*)", scope: "project" }, + ]); +}); diff --git a/crates/agent-gui/test/tools/tool-approval-remember.test.mjs b/crates/agent-gui/test/tools/tool-approval-remember.test.mjs new file mode 100644 index 000000000..a30e09dc4 --- /dev/null +++ b/crates/agent-gui/test/tools/tool-approval-remember.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-2a: K-brain approvals wait without a deadline, "allow and remember" carries the +// scope the backend should store the suggested rules in, and a denial can carry a +// reason for the model. + +const loader = createTsModuleLoader(); +const approval = loader.loadModule("src/lib/tools/toolApproval.ts"); +const { requestToolApproval, answerToolApproval, listPendingToolApprovalsForConversation } = approval; + +test("timeoutMs null waits without a deadline and never settles on its own", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + let settled = null; + const promise = requestToolApproval({ + toolCallId: "wait-1", + toolName: "Bash", + conversationId: "conv-wait", + timeoutMs: null, + }).then((value) => { + settled = value; + }); + assert.equal(listPendingToolApprovalsForConversation("conv-wait")[0].deadlineAt, null); + t.mock.timers.tick(60 * 60 * 1000); + await Promise.resolve(); + assert.equal(settled, null, "an hour later the approval is still pending"); + answerToolApproval("wait-1", "approve"); + await promise; + assert.deepEqual(settled, { kind: "decided", decision: "approve" }); +}); + +test("callers that pass no timeout keep the three-minute window", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + const promise = requestToolApproval({ toolCallId: "legacy-1", toolName: "Bash", conversationId: "conv-legacy" }); + assert.equal(typeof listPendingToolApprovalsForConversation("conv-legacy")[0].deadlineAt, "number"); + t.mock.timers.tick(3 * 60 * 1000); + assert.deepEqual(await promise, { kind: "timeout" }); +}); + +test("remember rules and default scope are listed for the bar", () => { + const promise = requestToolApproval({ + toolCallId: "rules-1", + toolName: "Bash", + conversationId: "conv-rules", + allowSession: true, + rememberRules: [" Bash(npm test:*) ", "", "Bash(npm test:*)", "Bash(git push:*)"], + defaultScope: "project", + timeoutMs: null, + }); + const [pending] = listPendingToolApprovalsForConversation("conv-rules"); + assert.deepEqual(pending.rememberRules, ["Bash(npm test:*)", "Bash(git push:*)"]); + assert.equal(pending.defaultScope, "project"); + answerToolApproval("rules-1", "deny"); + return promise; +}); + +test("an unknown default scope falls back to session", () => { + const promise = requestToolApproval({ + toolCallId: "rules-2", + toolName: "Bash", + conversationId: "conv-rules-2", + allowSession: true, + rememberRules: ["Bash(ls:*)"], + defaultScope: "global", + timeoutMs: null, + }); + assert.equal(listPendingToolApprovalsForConversation("conv-rules-2")[0].defaultScope, "session"); + answerToolApproval("rules-2", "deny"); + return promise; +}); + +test("approve_session carries a valid scope only when there are rules to remember", async () => { + const withRules = requestToolApproval({ + toolCallId: "scope-1", + toolName: "Bash", + conversationId: "conv-scope", + allowSession: true, + rememberRules: ["Bash(npm test:*)"], + defaultScope: "project", + timeoutMs: null, + }); + answerToolApproval("scope-1", "approve_session", { scope: "user" }); + assert.deepEqual(await withRules, { kind: "decided", decision: "approve_session", scope: "user" }); + + const badScope = requestToolApproval({ + toolCallId: "scope-2", + toolName: "Bash", + conversationId: "conv-scope", + allowSession: true, + rememberRules: ["Bash(npm test:*)"], + timeoutMs: null, + }); + answerToolApproval("scope-2", "approve_session", { scope: "everywhere" }); + assert.deepEqual(await badScope, { kind: "decided", decision: "approve_session" }); + + // A backend without rules (computer session grant) ignores any scope. + const noRules = requestToolApproval({ + toolCallId: "scope-3", + toolName: "computer_exec", + conversationId: "conv-scope", + allowSession: true, + sessionScope: "call:click", + timeoutMs: null, + }); + answerToolApproval("scope-3", "approve_session", { scope: "project" }); + assert.deepEqual(await noRules, { kind: "decided", decision: "approve_session" }); + + // Nothing to remember at all: approve_session is only this once, without a scope. + const notOffered = requestToolApproval({ toolCallId: "scope-4", toolName: "Bash", conversationId: "conv-scope", timeoutMs: null }); + answerToolApproval("scope-4", "approve_session", { scope: "project" }); + assert.deepEqual(await notOffered, { kind: "decided", decision: "approve" }); +}); + +test("a denial keeps its trimmed reason; other decisions drop it", async () => { + const denied = requestToolApproval({ toolCallId: "reason-1", toolName: "Write", conversationId: "conv-reason", timeoutMs: null }); + answerToolApproval("reason-1", "deny", { reason: " edit utils.ts instead " }); + assert.deepEqual(await denied, { kind: "decided", decision: "deny", reason: "edit utils.ts instead" }); + + const blank = requestToolApproval({ toolCallId: "reason-2", toolName: "Write", conversationId: "conv-reason", timeoutMs: null }); + answerToolApproval("reason-2", "deny", { reason: " " }); + assert.deepEqual(await blank, { kind: "decided", decision: "deny" }); + + const approved = requestToolApproval({ toolCallId: "reason-3", toolName: "Write", conversationId: "conv-reason", timeoutMs: null }); + answerToolApproval("reason-3", "approve", { reason: "fine" }); + assert.deepEqual(await approved, { kind: "decided", decision: "approve" }); + + const long = requestToolApproval({ toolCallId: "reason-4", toolName: "Write", conversationId: "conv-reason", timeoutMs: null }); + answerToolApproval("reason-4", "deny", { reason: "x".repeat(5000) }); + assert.equal((await long).reason.length, 2000); +}); diff --git a/crates/agent-ui/src/components/chat/ToolApprovalBar.tsx b/crates/agent-ui/src/components/chat/ToolApprovalBar.tsx index e878dd3fd..a23bbdac2 100644 --- a/crates/agent-ui/src/components/chat/ToolApprovalBar.tsx +++ b/crates/agent-ui/src/components/chat/ToolApprovalBar.tsx @@ -16,7 +16,10 @@ import { import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useEffect, useRef, useState } from "react"; -import { ASK_USER_QUESTION_TIMEOUT_MS } from "../../lib/chat/askUserQuestion"; +import type { + ToolApprovalAnswerOptions, + ToolApprovalRememberScope, +} from "../../lib/chat/toolApprovalArgs"; /** approve:本次放行;deny:本次拒绝;approve_session:请后端在本会话内记住这条授权范围。 */ export type ToolApprovalDecision = "approve" | "deny" | "approve_session"; @@ -28,14 +31,17 @@ export type PendingApprovalItem = { toolName: string; /** 命令/参数摘要(Bash 显示命令等);空则只显示工具名。 */ summary?: string; - /** 权威应答截止时间戳(毫秒);缺省以挂载时刻近似。 */ - deadlineAt?: number; + /** 权威应答截止时间戳(毫秒);缺省或 null 表示一直等到用户决定,不显示倒计时(K-brain 审批)。 */ + deadlineAt?: number | null; /** 后端会记住本会话授权时为 true 才提供 approve_session;缺省不提供(远端旧数据同样不显示)。 */ allowSession?: boolean; /** approve_session 生效的授权范围(如 call:click),显示在该选项里。 */ sessionScope?: string; /** 本次允许即对本会话同类操作生效(如 Computer Use);正文与按钮据此说明。 */ sessionWide?: boolean; + /** 允许并记住会存的规则;非空时可以选作用域。 */ + rememberRules?: string[]; + defaultScope?: ToolApprovalRememberScope; }; function formatCountdown(remainingMs: number) { @@ -45,20 +51,27 @@ function formatCountdown(remainingMs: number) { return `${minutes}:${String(seconds).padStart(2, "0")}`; } -// 倒计时取列表中最早的 deadline(最紧迫者驱动整栏)。缺失时以挂载时刻近似。 -// 超时后桌面按“拒绝”落定,pending 消失,本栏随之隐藏。 +// 倒计时取列表中最早的 deadline(最紧迫者驱动整栏)。没有任何 deadline 时(K-brain +// 审批一直等到用户决定)返回 null,不显示倒计时。超时后桌面按“拒绝”落定,pending +// 消失,本栏随之隐藏。 function useEarliestCountdown(active: boolean, deadlines: number[]) { - const [fallbackDeadline] = useState(() => Date.now() + ASK_USER_QUESTION_TIMEOUT_MS); - const earliest = deadlines.length > 0 ? Math.min(...deadlines) : fallbackDeadline; - const [remainingMs, setRemainingMs] = useState(() => earliest - Date.now()); + const earliest = deadlines.length > 0 ? Math.min(...deadlines) : null; + const [remainingMs, setRemainingMs] = useState(() => + earliest === null ? null : earliest - Date.now(), + ); useEffect(() => { - if (!active) return; + if (!active || earliest === null) { + setRemainingMs(null); + return; + } const tick = () => setRemainingMs(earliest - Date.now()); tick(); const timer = setInterval(tick, 1000); return () => clearInterval(timer); }, [active, earliest]); - return remainingMs; + // The deadline set can change between renders; never show a stale countdown for it. + if (earliest === null) return null; + return remainingMs ?? earliest - Date.now(); } export function ToolApprovalBar({ @@ -70,6 +83,7 @@ export function ToolApprovalBar({ onDecide: ( toolCallId: string, decision: ToolApprovalDecision, + options?: ToolApprovalAnswerOptions, ) => Promise; onDecideAll: (decision: "approve" | "deny") => Promise; }) { @@ -200,14 +214,22 @@ export function ToolApprovalBar({
{displayToolName} - - {pending.length > 1 ? `1 / ${pending.length} · ` : null} - {formatCountdown(remainingMs)} - + {remainingMs === null ? ( + pending.length > 1 ? ( + + {`1 / ${pending.length}`} + + ) : null + ) : ( + + {pending.length > 1 ? `1 / ${pending.length} · ` : null} + {formatCountdown(remainingMs)} + + )}
{/* Wraps instead of truncating: on phones the session-wide explanation must stay readable. */} diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx index 6ee948c87..837cc7cfa 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx @@ -4,6 +4,7 @@ import { submitAskUserQuestionAnswers, submitPlanDecision, usePendingToolApproval, + usePermissionAutoResolved, usePlanDecisionState, } from "@liveagent/adapters/assistantBubble"; import { AskUserQuestionCard } from "@liveagent/ui/components/chat/AskUserQuestionCard"; @@ -52,6 +53,7 @@ import { isBuiltinShareToolName, isSubagentCardToolCall, } from "./assistantBubbleUtils"; +import { ToolPermissionMarker } from "./ToolPermissionMarker"; import { ShellToolDisplay, ToolArgsDisplay, ToolResultDisplay } from "./ToolResultDisplay"; // 折叠摘要里行内命令的展示上限:远超任何实际窗口一行可容纳的字符数,视觉 @@ -170,6 +172,11 @@ function ToolCallItem({ // 工具审批由宿主适配器读取。审批发生在工具执行前,不能用 isRunning 作门。 const pendingApproval = usePendingToolApproval(item.toolCall.id, item.toolCall.arguments); const isApprovalPending = !readOnly && !isRedactedToolContent && !result && pendingApproval; + // 后端没有询问就放行(只读或已记住的规则)时,工具行带一个小标记说明原因。 + const permissionAutoResolved = usePermissionAutoResolved( + item.toolCall.id, + item.toolCall.arguments, + ); const shouldAutoOpen = !isRedactedToolContent && (item.toolCall.name === "Image" || builtinResultKind === "display_image" || shouldKeepAskOpen); @@ -355,6 +362,7 @@ function ToolCallItem({ /> ) : null} + ) : compactChip ? ( <> @@ -401,6 +409,7 @@ function ToolCallItem({ )} + {displayIsRunning || result?.isError || shellSessionFailed ? ( {statusLabel} ) : null} @@ -442,6 +451,7 @@ function ToolCallItem({
+ {displayIsRunning ? ( + {label} + + ); +} diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index 3bce16798..93a7d4f6c 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -898,6 +898,9 @@ export const EN_US_COMMON_TRANSLATIONS = { "chat.toolApproval.deny": "Deny", "chat.toolApproval.timeoutHint": "until it is auto-denied", "chat.toolApproval.failed": "Submit failed. Please retry.", + "chat.toolPermission.readOnly": "Read-only, allowed", + "chat.toolPermission.rule": "Allowed by rule", + "chat.toolPermission.ruleTitle": "Remembered rule: {rule}", "chat.tool.expandActivity": "Expand tool activity", "chat.tool.collapseActivity": "Collapse tool activity", "chat.hookWarningTitle": "Hooks Warning", diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 038914156..119affdd8 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -846,6 +846,9 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "chat.toolApproval.deny": "拒绝", "chat.toolApproval.timeoutHint": "后自动拒绝", "chat.toolApproval.failed": "提交失败,请重试", + "chat.toolPermission.readOnly": "只读,自动允许", + "chat.toolPermission.rule": "按规则允许", + "chat.toolPermission.ruleTitle": "已记住的规则:{rule}", "chat.tool.expandActivity": "展开工具活动", "chat.tool.collapseActivity": "收起工具活动", "chat.hookWarningTitle": "Hooks 告警", diff --git a/crates/agent-ui/src/lib/chat/toolApprovalArgs.ts b/crates/agent-ui/src/lib/chat/toolApprovalArgs.ts index adf61009c..5bc8a7be6 100644 --- a/crates/agent-ui/src/lib/chat/toolApprovalArgs.ts +++ b/crates/agent-ui/src/lib/chat/toolApprovalArgs.ts @@ -16,10 +16,144 @@ export const TOOL_APPROVAL_ALLOW_SESSION_ARG = "__toolApprovalAllowSession"; export const TOOL_APPROVAL_SESSION_SCOPE_ARG = "__toolApprovalSessionScope"; /** 本次允许即对本会话同类操作生效(如 Computer Use);审批栏据此在批准前说明。 */ export const TOOL_APPROVAL_SESSION_WIDE_ARG = "__toolApprovalSessionWide"; +/** "允许并记住"会存下的规则(K-brain permission_rules);缺省时不能选作用域。 */ +export const TOOL_APPROVAL_REMEMBER_RULES_ARG = "__toolApprovalRememberRules"; +/** 记住规则的默认作用域:session / project / user。 */ +export const TOOL_APPROVAL_DEFAULT_SCOPE_ARG = "__toolApprovalDefaultScope"; +/** 后端没有询问就放行了这次调用(permission.auto_resolved):{source, rule?, scope?}。 */ +export const TOOL_PERMISSION_AUTO_RESOLVED_ARG = "__toolPermissionAutoResolved"; -/** 审批决定:allow=本次放行;deny=本次拒绝;approve_session=请后端在本对话内记住这条授权范围。 */ +/** 审批决定:allow=本次放行;deny=本次拒绝;approve_session=请后端记住这条授权范围。 */ export type ToolApprovalDecision = "approve" | "deny" | "approve_session"; +/** "允许并记住"存规则的作用域,与 K-brain permission_rules 的 scope 同名。 */ +export type ToolApprovalRememberScope = "session" | "project" | "user"; + +/** 决定之外的附带信息:approve_session 的作用域、deny 交给模型的说明。 */ +export type ToolApprovalAnswerOptions = { scope?: ToolApprovalRememberScope; reason?: string }; + +/** 拒绝说明的长度上限,与审批摘要的 2000 字上限一致。 */ +export const TOOL_APPROVAL_REASON_MAX_CHARS = 2000; + +export function normalizeToolApprovalScope(value: unknown): ToolApprovalRememberScope | undefined { + return value === "session" || value === "project" || value === "user" ? value : undefined; +} + +export function normalizeToolApprovalReason(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, TOOL_APPROVAL_REASON_MAX_CHARS) : undefined; +} + +/** 截短给人看的审批文本(规则、作用域说明);超长时以省略号结尾。 */ +export function clampApprovalText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + let end = Math.max(0, maxChars - 1); + // Never leave half of a surrogate pair at the cut. + const last = text.charCodeAt(end - 1); + if (end > 0 && last >= 0xd800 && last <= 0xdbff) end -= 1; + return `${text.slice(0, end)}…`; +} + +/** + * 同步给 WebUI 的记住规则按序列化后的总长度截断:解释器命令的建议是整条命令的 + * 精确规则,可能和命令一样长;引号、反斜杠在 JSON 里还会变长,所以按序列化长度量。 + * 规则在 WebUI 只用于展示(回答只带作用域),截断无害。 + */ +export function clampApprovalRules(rules: string[], maxChars: number): string[] { + const out: string[] = []; + const fits = (list: string[]) => JSON.stringify(list).length <= maxChars; + for (const rule of rules) { + if (fits([...out, rule])) { + out.push(rule); + continue; + } + // The longest cut of this rule that still fits; then stop adding rules. + let low = 0; + let high = rule.length; + let best = ""; + while (low <= high) { + const length = Math.floor((low + high) / 2); + const text = clampApprovalText(rule, length); + if (fits([...out, text])) { + best = text; + low = length + 1; + } else high = length - 1; + } + if (best.length > 1) out.push(best); + break; + } + return out; +} + +/** 去重、去空的记住规则列表。 */ +export function normalizeToolApprovalRules(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const rules: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") continue; + const rule = entry.trim(); + if (rule && !rules.includes(rule)) rules.push(rule); + } + return rules; +} + +/** WebUI 发给桌面的 chat_queue.tool_approval request_json。 */ +export function buildToolApprovalRequestJson( + decision: ToolApprovalDecision, + options?: ToolApprovalAnswerOptions, +): string { + const scope = + decision === "approve_session" ? normalizeToolApprovalScope(options?.scope) : undefined; + const reason = decision === "deny" ? normalizeToolApprovalReason(options?.reason) : undefined; + return JSON.stringify({ decision, ...(scope ? { scope } : {}), ...(reason ? { reason } : {}) }); +} + +/** 桌面端解析 request_json;旧 WebUI 只发 {decision}。决定非法时返回 null。 */ +export function parseToolApprovalRequestJson( + requestJson: string | undefined, +): ({ decision: ToolApprovalDecision } & ToolApprovalAnswerOptions) | null { + let value: unknown; + try { + value = JSON.parse(requestJson || "{}"); + } catch { + return null; + } + const record = value && typeof value === "object" ? (value as Record) : null; + const decision = record ? record.decision : value; + if (decision !== "approve" && decision !== "deny" && decision !== "approve_session") return null; + const scope = normalizeToolApprovalScope(record?.scope); + const reason = normalizeToolApprovalReason(record?.reason); + return { decision, ...(scope ? { scope } : {}), ...(reason ? { reason } : {}) }; +} + +export type ToolPermissionAutoResolved = { + source: "rule" | "read_only"; + rule?: string; + scope?: ToolApprovalRememberScope; +}; + +/** 规范化 permission.auto_resolved 的标记;来源未知时返回 null。 */ +export function normalizeToolPermissionAutoResolved( + value: unknown, +): ToolPermissionAutoResolved | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const source = record.source; + if (source !== "rule" && source !== "read_only") return null; + const rule = + typeof record.rule === "string" && record.rule.trim() ? record.rule.trim() : undefined; + const scope = normalizeToolApprovalScope(record.scope); + return { source, ...(rule ? { rule } : {}), ...(scope ? { scope } : {}) }; +} + +export function readToolPermissionAutoResolved(args: unknown): ToolPermissionAutoResolved | null { + if (!args || typeof args !== "object") return null; + return normalizeToolPermissionAutoResolved( + (args as Record)[TOOL_PERMISSION_AUTO_RESOLVED_ARG], + ); +} + export function readToolApprovalPending(args: unknown): boolean { if (!args || typeof args !== "object") return false; return (args as Record)[TOOL_APPROVAL_PENDING_ARG] === true; @@ -40,6 +174,9 @@ export type RemoteToolApprovalItem = { allowSession: boolean; sessionScope?: string; sessionWide: boolean; + /** 允许并记住会存的规则;非空时才能选作用域。 */ + rememberRules?: string[]; + defaultScope?: ToolApprovalRememberScope; }; /** 从同步过来的工具调用参数还原待审批项;未待审批时返回 null。两个 WebUI 收集器共用。 */ @@ -60,6 +197,16 @@ export function pendingToolApprovalFromToolCall(toolCall: { allowSession: record[TOOL_APPROVAL_ALLOW_SESSION_ARG] === true, sessionScope: typeof scope === "string" && scope.trim() ? scope.trim() : undefined, sessionWide: record[TOOL_APPROVAL_SESSION_WIDE_ARG] === true, + ...rememberFields(record), + }; +} + +function rememberFields(record: Record) { + const rules = normalizeToolApprovalRules(record[TOOL_APPROVAL_REMEMBER_RULES_ARG]); + if (rules.length === 0) return {}; + return { + rememberRules: rules, + defaultScope: normalizeToolApprovalScope(record[TOOL_APPROVAL_DEFAULT_SCOPE_ARG]) ?? "session", }; } diff --git a/crates/agent-ui/src/lib/chat/uiMessages.ts b/crates/agent-ui/src/lib/chat/uiMessages.ts index 6a0553de8..f7c276728 100644 --- a/crates/agent-ui/src/lib/chat/uiMessages.ts +++ b/crates/agent-ui/src/lib/chat/uiMessages.ts @@ -23,10 +23,13 @@ import { import { TOOL_APPROVAL_ALLOW_SESSION_ARG, TOOL_APPROVAL_DEADLINE_ARG, + TOOL_APPROVAL_DEFAULT_SCOPE_ARG, TOOL_APPROVAL_PENDING_ARG, + TOOL_APPROVAL_REMEMBER_RULES_ARG, TOOL_APPROVAL_SESSION_SCOPE_ARG, TOOL_APPROVAL_SESSION_WIDE_ARG, TOOL_APPROVAL_SUMMARY_ARG, + TOOL_PERMISSION_AUTO_RESOLVED_ARG, } from "@liveagent/ui/lib/chat/toolApprovalArgs"; import { fileToolFieldChars, LIVE_TOOL_PREVIEW_META_KEY } from "@liveagent/ui/lib/chat/toolPreview"; import { @@ -565,6 +568,9 @@ const DISPLAY_SYNTHETIC_ARG_KEYS = new Set([ TOOL_APPROVAL_ALLOW_SESSION_ARG, TOOL_APPROVAL_SESSION_SCOPE_ARG, TOOL_APPROVAL_SESSION_WIDE_ARG, + TOOL_APPROVAL_REMEMBER_RULES_ARG, + TOOL_APPROVAL_DEFAULT_SCOPE_ARG, + TOOL_PERMISSION_AUTO_RESOLVED_ARG, ASK_USER_QUESTION_DEADLINE_ARG, EXIT_PLAN_MODE_PENDING_ARG, EXIT_PLAN_MODE_APPROVED_ARG,