Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/agent-gui/src/lib/kbrain/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,11 @@ export type KBrainTurnParams = {
request: KBrainQuestionRequest,
signal?: AbortSignal,
) => Promise<KBrainQuestionAnswer[]>;
/** refusal: K-brain's reason for refusing the previous answer to this prompt (400). */
onPermissionRequest?: (
request: KBrainPermissionRequest,
signal?: AbortSignal,
refusal?: string,
) => Promise<KBrainPermissionAnswer>;
onPermissionAutoResolved?: (resolved: KBrainPermissionAutoResolved) => void;
applyConversationState?: (state: ConversationViewState) => void;
Expand Down Expand Up @@ -926,8 +928,9 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
pendingPermissions.set(request.permission_id, permissionController);
const onPermissionRequest = params.onPermissionRequest;
const answerPermission = async () => {
let refusal: string | undefined;
for (let attempt = 0; ; attempt += 1) {
const answer = await onPermissionRequest(request, permissionController.signal);
const answer = await onPermissionRequest(request, permissionController.signal, refusal);
if (permissionController.signal.aborted) return;
const { decision, scope, reason } = permissionAnswerFields(answer);
try {
Expand All @@ -951,6 +954,7 @@ async function executeKBrainTurn(params: KBrainTurnParams): Promise<AssistantMes
attempt < PERMISSION_ANSWER_RETRIES &&
!permissionController.signal.aborted
) {
refusal = error instanceof Error ? error.message : String(error);
continue;
}
throw error;
Expand Down
13 changes: 13 additions & 0 deletions crates/agent-gui/src/lib/tools/toolApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
normalizeToolApprovalRules,
normalizeToolApprovalScope,
type ToolApprovalAnswerOptions,
type ToolApprovalPreview,
type ToolApprovalRememberScope,
} from "@liveagent/ui/lib/chat/toolApprovalArgs";

Expand Down Expand Up @@ -43,6 +44,10 @@ type PendingToolApproval = {
/** 允许并记住时后端会存的规则;空表示只能按本会话授权(或不能记住)。 */
rememberRules: string[];
defaultScope: ToolApprovalRememberScope;
/** Edit/Write 的改动预览。 */
preview?: ToolApprovalPreview;
/** 审批重新弹出的原因(后端拒绝了上次的应答),空表示没有。 */
notice: string;
settle: (settlement: ToolApprovalSettlement) => void;
};

Expand Down Expand Up @@ -120,6 +125,8 @@ export type PendingToolApprovalSummary = {
sessionWide: boolean;
rememberRules: string[];
defaultScope: ToolApprovalRememberScope;
preview?: ToolApprovalPreview;
notice?: string;
};

export function listPendingToolApprovalsForConversation(
Expand All @@ -139,6 +146,8 @@ export function listPendingToolApprovalsForConversation(
sessionWide: pending.sessionWide,
rememberRules: pending.rememberRules,
defaultScope: pending.defaultScope,
...(pending.preview ? { preview: pending.preview } : {}),
...(pending.notice ? { notice: pending.notice } : {}),
});
}
}
Expand Down Expand Up @@ -207,6 +216,8 @@ export function requestToolApproval(params: {
sessionWide?: boolean;
rememberRules?: string[];
defaultScope?: string;
preview?: ToolApprovalPreview;
notice?: string;
signal?: AbortSignal;
timeoutMs?: number | null;
}): Promise<ToolApprovalSettlement> {
Expand Down Expand Up @@ -246,6 +257,8 @@ export function requestToolApproval(params: {
sessionWide: params.sessionWide === true,
rememberRules: normalizeToolApprovalRules(params.rememberRules),
defaultScope: normalizeToolApprovalScope(params.defaultScope) ?? "session",
...(params.preview ? { preview: params.preview } : {}),
notice: params.notice?.trim() ?? "",
settle,
};
pendingByToolCallId.set(toolCallId, pending);
Expand Down
27 changes: 26 additions & 1 deletion crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ import {
TOOL_APPROVAL_ALLOW_SESSION_ARG,
TOOL_APPROVAL_DEADLINE_ARG,
TOOL_APPROVAL_DEFAULT_SCOPE_ARG,
TOOL_APPROVAL_NOTICE_ARG,
TOOL_APPROVAL_PENDING_ARG,
TOOL_APPROVAL_PREVIEW_ARG,
TOOL_APPROVAL_REMEMBER_RULES_ARG,
TOOL_APPROVAL_SESSION_SCOPE_ARG,
TOOL_APPROVAL_SESSION_WIDE_ARG,
TOOL_APPROVAL_SUMMARY_ARG,
TOOL_APPROVAL_SYNTHETIC_ARG_KEYS,
TOOL_PERMISSION_AUTO_RESOLVED_ARG,
} from "@liveagent/ui/lib/chat/toolApprovalArgs";
import {
Expand Down Expand Up @@ -135,14 +138,27 @@ function buildHeadTailPreview(input: string, maxChars = GATEWAY_TOOL_TEXT_PREVIE
};
}

/**
* Approval markers come only from the desktop's own store: the same keys in the model's
* arguments are dropped before anything is synced, or a model could fake the WebUI
* approval card. Every tool_call sent to the Gateway goes through this.
*/
export function withoutApprovalSyntheticArgs(
args: Record<string, unknown> | undefined,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(args || {}).filter(([key]) => !TOOL_APPROVAL_SYNTHETIC_ARG_KEYS.includes(key)),
);
}

// The canonical producer of streaming tool previews: bridge events
// (tool_call / tool_call_delta / tool_result) and runtime snapshot entries
// all pass through here, so every remote representation of a file tool's
// args carries the same truncated text + true metrics + monotonic progress.
export function buildGatewayToolCallPreviewArguments(
toolCall: Pick<ToolCall, "id" | "name" | "arguments">,
) {
const sourceArgs = toolCall.arguments || {};
const sourceArgs = withoutApprovalSyntheticArgs(toolCall.arguments);
// 待审批标记:任意工具在 beforeToolCall 处挂起等待批准时,盖到同步给 WebUI 的
// 参数上,让远端渲染审批卡片并显示与桌面同源的倒计时。审批消解后重发的快照
// 不再带此标记,卡片随之隐藏。__ 前缀合成参数不入展示、不影响本地执行。
Expand Down Expand Up @@ -181,6 +197,15 @@ export function buildGatewayToolCallPreviewArguments(
[TOOL_APPROVAL_DEFAULT_SCOPE_ARG]: pending.defaultScope,
}
: {}),
...(pending.preview ? { [TOOL_APPROVAL_PREVIEW_ARG]: pending.preview } : {}),
...(pending.notice
? {
[TOOL_APPROVAL_NOTICE_ARG]: clampApprovalText(
pending.notice,
TOOL_APPROVAL_SUMMARY_MAX_CHARS,
),
}
: {}),
...(autoResolved ? { [TOOL_PERMISSION_AUTO_RESOLVED_ARG]: autoResolved } : {}),
}
: autoResolved
Expand Down
17 changes: 13 additions & 4 deletions crates/agent-gui/src/pages/chat/turns/runKBrainConversationTurn.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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 {
approvalPreviewFromToolCall,
clampApprovalText,
} from "@liveagent/ui/lib/chat/toolApprovalArgs";
import { appendMessagesToConversation } from "../../../lib/chat/conversation/conversationState";
import { buildConversationStateFromWindow } from "../../../lib/chat/history/chatHistory";
import {
Expand Down Expand Up @@ -38,7 +41,10 @@ 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 {
buildGatewayToolCallPreviewArguments,
withoutApprovalSyntheticArgs,
} from "./gatewayToolPreview";
import type { RunAgentConversationTurnParams } from "./runAgentConversationTurn";
import type { RunTextConversationTurnParams } from "./runTextConversationTurn";

Expand Down Expand Up @@ -162,7 +168,7 @@ export async function runKBrainConversationTurn(params: Params): Promise<void> {
name: call.name,
arguments: autoResolvedCallIds.has(call.id)
? buildGatewayToolCallPreviewArguments(call)
: call.arguments,
: withoutApprovalSyntheticArgs(call.arguments),
conversation_id: params.conversationId,
round,
});
Expand Down Expand Up @@ -298,7 +304,7 @@ export async function runKBrainConversationTurn(params: Params): Promise<void> {
...(answer.custom ? { custom: true } : {}),
}));
},
onPermissionRequest: async (request, signal) => {
onPermissionRequest: async (request, signal, refusal) => {
// Key the approval by its tool call so the Gateway preview overlays the
// pending marker and scope on that call for WebUI; re-push the call when
// the approval starts and settles. Without a known, free call id the
Expand Down Expand Up @@ -333,6 +339,9 @@ export async function runKBrainConversationTurn(params: Params): Promise<void> {
sessionWide: request.scope === "session",
rememberRules,
defaultScope: request.suggestion?.default_scope,
// The edit being approved, from the call's full arguments.
preview: call ? approvalPreviewFromToolCall(request.tool, call.arguments) : undefined,
notice: refusal,
signal: signal
? AbortSignal.any([approvalController.signal, signal])
: approvalController.signal,
Expand Down
151 changes: 151 additions & 0 deletions crates/agent-gui/test/chat/review-la2b-approval-card-r2.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDomTestEnv } from "../helpers/dom-test-env.mjs";
import { createTsModuleLoader } from "../helpers/load-ts-module.mjs";

// LA-2b review, round 2. The desktop preview is now capped by deriveFileToolPreview,
// which keeps the first 4000 chars of each side independently. Diffing two independently
// head-cut sides hides or invents changes. Assertions compare strings, numbers and
// booleans only: a failing assert on a DOM node would print the whole jsdom tree.

const lines = (n, f) => Array.from({ length: n }, (_, i) => f(i));

async function createEnv() {
const env = await createDomTestEnv({
mocks: {
"@liveagent/ui/components/IconSet": new Proxy({}, { get: () => () => null }),
"@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),
},
},
});
return env;
}

// Renders the real approval card (real EditDiffView) for a desktop Edit call and returns
// the changed rows the user sees.
async function renderDesktopEdit(editArgs) {
const env = await createEnv();
const { ToolApprovalBar } = env.loadModule("@liveagent/ui/components/chat/ToolApprovalBar.tsx");
const args = env.loadModule("@liveagent/ui/lib/chat/toolApprovalArgs.ts");
// Exactly what runKBrainConversationTurn does with the call's full arguments.
const preview = args.approvalPreviewFromToolCall("Edit", editArgs);
const host = document.body.appendChild(document.createElement("div"));
const root = env.createRoot(host);
try {
await env.act(async () =>
root.render(
env.React.createElement(ToolApprovalBar, {
pending: [{ toolCallId: "e1", toolName: "Edit", summary: editArgs.path, preview }],
onDecide: async () => ({ ok: true }),
onDecideAll: async () => {},
}),
),
);
const changed = [...host.querySelectorAll("[data-chat-code-diff] .grid")]
.map((row) => {
const sign = row.className.includes("emerald") ? "+" : row.className.includes("red") ? "-" : " ";
return [sign, row.querySelector("code")?.textContent ?? ""];
})
.filter(([sign]) => sign !== " ");
const header = host.querySelector("[data-chat-code-diff] figcaption")?.textContent ?? "";
return { changed, header, truncated: preview?.truncated ?? null };
} finally {
await env.act(async () => root.unmount());
host.remove();
env.cleanup();
}
}

test("a desktop Edit whose change lies past 4000 chars still shows the change", async () => {
const body = lines(150, (i) => ` const item${i} = load(${i});`);
const editArgs = {
path: "src/items.ts",
old_string: [...body, " return 1;"].join("\n"),
new_string: [...body, " return 2;"].join("\n"),
};
// Baseline: WebUI's Gateway copy (head + tail) keeps the change.
const loader = createTsModuleLoader();
const approval = loader.loadModule("src/lib/tools/toolApproval.ts");
const { buildGatewayToolCallPreviewArguments } = loader.loadModule("src/pages/chat/turns/gatewayToolPreview.ts");
const args = loader.loadModule("../agent-ui/src/lib/chat/toolApprovalArgs.ts");
const call = { type: "toolCall", id: "review-r2-tail", name: "Edit", arguments: editArgs };
approval.requestToolApproval({ toolCallId: call.id, toolName: "Edit", summary: "x", conversationId: "conv-r2", timeoutMs: null });
const remote = args.pendingToolApprovalFromToolCall({ ...call, arguments: buildGatewayToolCallPreviewArguments(call) });
approval.answerToolApproval(call.id, "deny", { conversationId: "conv-r2" });
assert.equal(remote?.preview?.after.includes("return 2;"), true, "WebUI preview shows the change");

const desktop = await renderDesktopEdit(editArgs);
const facts = { oldLen: editArgs.old_string.length, ...desktop };
assert.equal(
desktop.changed.some(([sign, text]) => sign === "+" && text.includes("return 2;")),
true,
`desktop card hides the edit it approves: ${JSON.stringify(facts)}`,
);
});

test("a desktop Edit cut on one side does not show kept lines as deleted", async () => {
const kept = lines(140, (i) => ` const item${i} = load(${i});`);
const oldText = kept.join("\n");
const newText = [...lines(30, (i) => ` import${i}();`), ...kept].join("\n");
assert.equal(oldText.length < 4000 && newText.length > 4000, true, "fixture: only new_string is over the cap");
const desktop = await renderDesktopEdit({ path: "src/items.ts", old_string: oldText, new_string: newText });
// Every old line survives in new_string, so a faithful preview deletes nothing.
const falseDeletions = desktop.changed.filter(([sign, text]) => sign === "-" && newText.includes(text));
assert.equal(
falseDeletions.length,
0,
`kept lines shown as deleted: ${JSON.stringify({ header: desktop.header, falseDeletions: falseDeletions.slice(0, 4), count: falseDeletions.length })}`,
);
});

// The "preview is cut" note sits at the end of the max-h-56 scroll box, after a diff that
// is always long when truncated (>= 4000 chars, ~100 rows), so it is below the fold.
test("the truncated-preview note is not hidden at the bottom of the scroll box", async () => {
const env = await createEnv();
const { ToolApprovalBar } = env.loadModule("@liveagent/ui/components/chat/ToolApprovalBar.tsx");
const args = env.loadModule("@liveagent/ui/lib/chat/toolApprovalArgs.ts");
const content = lines(300, (i) => `const value${i} = compute(${i});`).join("\n");
const preview = args.approvalPreviewFromToolCall("Write", { path: "src/big.ts", content });
const host = document.body.appendChild(document.createElement("div"));
const root = env.createRoot(host);
try {
await env.act(async () =>
root.render(
env.React.createElement(ToolApprovalBar, {
pending: [{ toolCallId: "w1", toolName: "Write", summary: "src/big.ts", preview }],
onDecide: async () => ({ ok: true }),
onDecideAll: async () => {},
}),
),
);
const note = [...host.querySelectorAll("p")].find((p) => /预览已截断/.test(p.textContent));
const scroller = note?.closest(".overflow-auto");
const diff = host.querySelector("[data-chat-code-diff]");
const facts = {
truncated: preview?.truncated ?? null,
noteFound: Boolean(note),
noteInScroller: Boolean(scroller),
scrollerClass: scroller?.className ?? "",
noteAfterDiff: Boolean(note && diff && diff.compareDocumentPosition(note) & 4),
diffRows: host.querySelectorAll("[data-chat-code-diff] code").length,
};
assert.equal(facts.noteFound, true, JSON.stringify(facts));
assert.equal(
facts.noteInScroller && facts.noteAfterDiff,
false,
`note is below a ${facts.diffRows}-row diff inside the scroll box: ${JSON.stringify(facts)}`,
);
} finally {
await env.act(async () => root.unmount());
host.remove();
env.cleanup();
}
});
Loading
Loading