feat: add permission request UI
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-06-08 13:32:50 +08:00
parent 5fc1812d53
commit e32823e4b5
9 changed files with 999 additions and 12 deletions
+601
View File
@@ -9,6 +9,9 @@ import {
Avatar,
Box,
Button,
Chip,
CircularProgress,
Collapse,
IconButton,
Paper,
Stack,
@@ -42,6 +45,15 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import type { PermissionReply } from "@/lib/chatStream";
type AgentTurnProps = {
message: Message;
@@ -55,6 +67,7 @@ type AgentTurnProps = {
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
};
const MarkdownBlock = ({ children }: { children: string }) => (
@@ -63,6 +76,586 @@ const MarkdownBlock = ({ children }: { children: string }) => (
</div>
);
const formatMetadataValue = (value: unknown) => {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
};
const truncateText = (value: string, maxLength: number) =>
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
const formatMetadata = (metadata: Record<string, unknown>) => {
const entries = Object.entries(metadata)
.filter(([key]) => !["command", "path", "file", "directory"].includes(key))
.slice(0, 3);
if (!entries.length) {
return "";
}
return entries
.map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`)
.join("");
};
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
if (permission.permission === "external_directory") return "访问工作区外目录";
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
return permission.permission || "工具权限请求";
};
const getPermissionPrimaryValue = (
permission: NonNullable<Message["permissions"]>[number],
) => {
const command = permission.metadata.command;
if (typeof command === "string" && command.trim()) {
return command.trim();
}
for (const key of ["path", "file", "directory"]) {
const value = permission.metadata[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return permission.patterns[0] ?? permission.permission;
};
const PermissionIcon = ({
permission,
}: {
permission: NonNullable<Message["permissions"]>[number];
}) => {
if (permission.permission === "bash") {
return <TerminalRounded sx={{ fontSize: 22 }} />;
}
if (permission.permission === "external_directory") {
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
}
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
};
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
if (status === "approved_always") return "已始终允许";
if (status === "approved_once") return "已允许一次";
if (status === "rejected") return "已拒绝";
if (status === "error") return "提交失败";
if (status === "submitting") return "提交中";
return "等待确认";
};
const pendingPermissionColor = "#f9a825";
const PermissionRequestCard = ({
permission,
onReply,
}: {
permission: NonNullable<Message["permissions"]>[number];
onReply: (requestId: string, reply: PermissionReply) => void;
}) => {
const theme = useTheme();
const isPending = permission.status === "pending" || permission.status === "error";
const isSubmitting = permission.status === "submitting";
const primaryValue = getPermissionPrimaryValue(permission);
const metadataText = formatMetadata(permission.metadata);
const accentColor =
permission.status === "rejected" || permission.status === "error"
? theme.palette.error.main
: permission.status === "pending" || permission.status === "submitting"
? pendingPermissionColor
: theme.palette.success.main;
const statusLabel = getPermissionStatusLabel(permission.status);
const statusColor =
permission.status === "rejected" || permission.status === "error"
? "error"
: permission.status === "pending" || permission.status === "submitting"
? "warning"
: "success";
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.5),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: accentColor,
},
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{
px: 1.5,
py: 1.25,
pl: 1.75,
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: accentColor,
bgcolor: alpha(accentColor, 0.1),
border: `1px solid ${alpha(accentColor, 0.16)}`,
}}
>
<PermissionIcon permission={permission} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
{getPermissionTitle(permission)}
</Typography>
</Box>
<Chip
size="small"
color={statusColor}
label={statusLabel}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
borderRadius: "12px",
bgcolor:
statusColor === "success"
? alpha(theme.palette.success.main, 0.12)
: statusColor === "error"
? alpha(theme.palette.error.main, 0.1)
: alpha(pendingPermissionColor, 0.14),
color:
statusColor === "success"
? theme.palette.success.dark
: statusColor === "error"
? theme.palette.error.main
: "#8a5a00",
"& .MuiChip-label": { px: 1 },
}}
/>
</Stack>
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
<Box
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha("#000", 0.025),
border: `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
<Typography
variant="body2"
color="text.primary"
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
sx={{
mt: 0.25,
lineHeight: 1.55,
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{primaryValue}
</Typography>
</Box>
{metadataText ? (
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}>
{metadataText}
</Typography>
) : null}
</Stack>
{permission.error ? (
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
<Typography
variant="caption"
color="error.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.error.main, 0.06),
wordBreak: "break-word",
}}
>
{permission.error}
</Typography>
</Box>
) : null}
{isPending || isSubmitting ? (
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
>
<Button
size="small"
variant="contained"
disableElevation
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 94,
height: 34,
borderRadius: "17px",
bgcolor: "#00838f",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": {
bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
},
}}
>
</Button>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "always")}
startIcon={<PushPinRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "#00838f",
borderColor: alpha("#00838f", 0.24),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha("#00838f", 0.36),
bgcolor: alpha("#00838f", 0.08),
},
}}
>
</Button>
<Button
size="small"
color="error"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07),
},
}}
>
</Button>
</Stack>
) : null}
</Box>
);
};
const PermissionRequestGroup = ({
permissions,
onReply,
}: {
permissions: NonNullable<Message["permissions"]>;
onReply: (requestId: string, reply: PermissionReply) => void;
}) => {
const theme = useTheme();
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount;
const hasPendingPermissions = pendingCount > 0;
const [expanded, setExpanded] = React.useState(false);
const latestPermissions = permissions.slice(-3);
const pendingPermissions = permissions.filter(
(permission) =>
permission.status === "pending" ||
permission.status === "submitting" ||
permission.status === "error",
);
const summaryItems = [
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
{ label: "允许一次", value: onceCount, color: "#00838f" },
{ label: "始终允许", value: alwaysCount, color: theme.palette.success.main },
{ label: "拒绝", value: rejectedCount, color: theme.palette.error.main },
];
const chipColor = pendingCount > 0 ? pendingPermissionColor : rejectedCount > 0 ? theme.palette.error.main : theme.palette.success.main;
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.46),
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
backdropFilter: "blur(20px)",
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
role="button"
tabIndex={0}
onClick={() => setExpanded((value) => !value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((value) => !value);
}
}}
sx={{
px: 1.5,
py: 1.15,
cursor: "pointer",
transition: "background-color 0.2s ease",
"&:hover": { bgcolor: alpha("#000", 0.025) },
}}
>
<Box
sx={{
width: 30,
height: 30,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: chipColor,
bgcolor: alpha(chipColor, 0.1),
border: `1px solid ${alpha(chipColor, 0.15)}`,
}}
>
<VerifiedUserRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Stack
direction="row"
flexWrap="wrap"
gap={0.6}
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
>
{summaryItems.map((item) => (
<Box
key={item.label}
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.45,
height: 22,
px: 0.8,
borderRadius: "11px",
bgcolor: alpha(item.color, 0.08),
border: `1px solid ${alpha(item.color, 0.12)}`,
color: item.color,
fontSize: "0.7rem",
fontWeight: 800,
lineHeight: 1,
whiteSpace: "nowrap",
}}
>
<Box component="span" sx={{ color: alpha(item.color, 0.82), fontWeight: 700 }}>
{item.label}
</Box>
<Box component="span">{item.value} </Box>
</Box>
))}
</Stack>
</Box>
<Chip
size="small"
label={`待确认 ${pendingCount}`}
sx={{
height: 24,
borderRadius: "12px",
fontSize: "0.7rem",
fontWeight: 800,
color: chipColor,
bgcolor: alpha(chipColor, 0.1),
"& .MuiChip-label": { px: 1 },
}}
/>
<IconButton
size="small"
aria-label={expanded ? "收起权限请求" : "展开权限请求"}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
{!expanded && !hasPendingPermissions && latestPermissions.length > 0 ? (
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
{latestPermissions.map((permission, index) => {
const primaryValue = getPermissionPrimaryValue(permission);
const isLast = index === latestPermissions.length - 1;
const itemColor =
permission.status === "rejected" || permission.status === "error"
? theme.palette.error.main
: permission.status === "approved_once" || permission.status === "approved_always"
? theme.palette.success.main
: pendingPermissionColor;
return (
<Stack
key={permission.requestId}
direction="row"
spacing={1}
alignItems="center"
sx={{
py: 0.8,
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: itemColor,
bgcolor: alpha(itemColor, 0.08),
}}
>
<PermissionIcon permission={permission} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
{getPermissionTitle(permission)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
display: "block",
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
}}
>
{truncateText(primaryValue, 72)}
</Typography>
</Box>
<Chip
size="small"
label={getPermissionStatusLabel(permission.status)}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.68rem",
fontWeight: 800,
color: itemColor,
bgcolor: alpha(itemColor, 0.08),
"& .MuiChip-label": { px: 0.85 },
}}
/>
</Stack>
);
})}
</Stack>
) : null}
<AnimatePresence initial={false}>
{!expanded && hasPendingPermissions ? (
<motion.div
key="pending-permissions"
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -8, height: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ overflow: "hidden" }}
>
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
{pendingPermissions.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
onReply={onReply}
/>
))}
</Stack>
</motion.div>
) : null}
</AnimatePresence>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
{permissions.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
onReply={onReply}
/>
))}
</Stack>
</Collapse>
</Box>
);
};
export const AgentTurn = React.memo(
({
message,
@@ -76,6 +669,7 @@ export const AgentTurn = React.memo(
onRegenerate,
onEditResubmit,
onCycleBranch,
onReplyPermission,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
@@ -359,6 +953,13 @@ export const AgentTurn = React.memo(
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null}
{message.permissions?.length ? (
<PermissionRequestGroup
permissions={message.permissions}
onReply={onReplyPermission}
/>
) : null}
<Box
sx={{
p: 1.5,
@@ -46,6 +46,7 @@ describe("AgentWorkspace", () => {
onRegenerate: jest.fn(),
onEditResubmit: jest.fn(),
onCycleBranch: jest.fn(),
onReplyPermission: jest.fn(),
};
beforeEach(() => {
+11 -1
View File
@@ -11,6 +11,7 @@ import MapRounded from "@mui/icons-material/MapRounded";
import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts";
import type { PermissionReply } from "@/lib/chatStream";
import type {
BranchGroup,
BranchState,
@@ -35,6 +36,7 @@ type AgentWorkspaceProps = {
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
};
type TurnListProps = {
@@ -50,6 +52,7 @@ type TurnListProps = {
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
};
const sameMessages = (left: Message[], right: Message[]) =>
@@ -69,6 +72,7 @@ const TurnListInner = ({
onRegenerate,
onEditResubmit,
onCycleBranch,
onReplyPermission,
}: TurnListProps) => {
const branchStateByRootId = React.useMemo(() => {
const next = new Map<string, BranchState>();
@@ -101,6 +105,7 @@ const TurnListInner = ({
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission}
/>
);
})}
@@ -122,7 +127,8 @@ const TurnList = React.memo(
prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onRegenerate === nextProps.onRegenerate &&
prevProps.onEditResubmit === nextProps.onEditResubmit &&
prevProps.onCycleBranch === nextProps.onCycleBranch,
prevProps.onCycleBranch === nextProps.onCycleBranch &&
prevProps.onReplyPermission === nextProps.onReplyPermission,
);
TurnList.displayName = "TurnList";
@@ -257,6 +263,7 @@ export const AgentWorkspace = ({
onRegenerate,
onEditResubmit,
onCycleBranch,
onReplyPermission,
}: AgentWorkspaceProps) => {
const theme = useTheme();
const latestAssistant = [...messages]
@@ -311,6 +318,7 @@ export const AgentWorkspace = ({
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission}
/>
{streamingMessage ? (
@@ -327,6 +335,7 @@ export const AgentWorkspace = ({
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission}
/>
) : null}
@@ -353,6 +362,7 @@ export const AgentWorkspace = ({
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission}
/>
</motion.div>
</AnimatePresence>
+2
View File
@@ -75,6 +75,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
editAndResubmit,
cycleBranch,
abort,
replyPermission,
createSession,
renameSession,
removeSession,
@@ -354,6 +355,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onRegenerate={regenerate}
onEditResubmit={editAndResubmit}
onCycleBranch={cycleBranch}
onReplyPermission={replyPermission}
/>
<AgentComposer
@@ -22,6 +22,31 @@ export type AgentArtifact = {
params: Record<string, unknown>;
};
export type AgentPermissionStatus =
| "pending"
| "submitting"
| "approved_once"
| "approved_always"
| "rejected"
| "error";
export type AgentPermissionRequest = {
requestId: string;
sessionId: string;
permission: string;
patterns: string[];
metadata: Record<string, unknown>;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
repliedAt?: number;
status: AgentPermissionStatus;
error?: string;
};
export type Message = {
id: string;
role: "user" | "assistant";
@@ -29,6 +54,7 @@ export type Message = {
isError?: boolean;
progress?: ChatProgress[];
artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[];
branchRootId?: string;
};
@@ -3,12 +3,18 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession";
import { abortAgentChat, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
import {
abortAgentChat,
replyAgentPermission,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined),
forkAgentChat: jest.fn(async () => "forked-session"),
replyAgentPermission: jest.fn(async () => undefined),
resumeAgentChatStream: jest.fn(async () => undefined),
streamAgentChat: jest.fn(async () => undefined),
}));
@@ -46,9 +52,11 @@ describe("useAgentChatSession", () => {
saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset();
jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
deleteChatSession.mockImplementation(async () => undefined);
@@ -353,6 +361,62 @@ describe("useAgentChatSession", () => {
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
});
it("tracks permission requests and submits replies", async () => {
listChatSessions.mockResolvedValue([]);
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
emitStreamEvent = onEvent;
await new Promise<void>(() => undefined);
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
void result.current.sendPrompt("删除临时文件");
await Promise.resolve();
});
act(() => {
emitStreamEvent?.({
type: "permission_request",
sessionId: "session-1",
requestId: "perm-1",
permission: "bash",
patterns: ["rm *"],
metadata: { command: "rm tmp.txt" },
always: ["rm *"],
createdAt: 123,
});
});
expect(result.current.messages.at(-1)?.permissions).toEqual([
expect.objectContaining({
requestId: "perm-1",
sessionId: "session-1",
status: "pending",
}),
]);
await act(async () => {
await result.current.replyPermission("perm-1", "once");
});
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
expect.objectContaining({
requestId: "perm-1",
status: "approved_once",
}),
);
});
it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(
@@ -368,7 +432,7 @@ describe("useAgentChatSession", () => {
startedAt: 1000,
} satisfies StreamEvent);
signal.addEventListener("abort", () => {
signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
}),
@@ -5,12 +5,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
abortAgentChat,
forkAgentChat,
replyAgentPermission,
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { AgentModel, StreamEvent } from "@/lib/chatStream";
import type { AgentModel, PermissionReply, StreamEvent } from "@/lib/chatStream";
import type {
AgentArtifact,
AgentPermissionRequest,
BranchGroup,
BranchTransition,
ChatProgress,
@@ -130,6 +132,41 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
};
});
const upsertPermission = (
permissions: AgentPermissionRequest[] | undefined,
event: StreamEvent & { type: "permission_request" },
) => {
const next = [...(permissions ?? [])];
const index = next.findIndex((item) => item.requestId === event.requestId);
const nextItem: AgentPermissionRequest = {
requestId: event.requestId,
sessionId: event.sessionId,
permission: event.permission,
patterns: event.patterns,
metadata: event.metadata,
always: event.always,
tool: event.tool,
createdAt: event.createdAt,
status: "pending",
};
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
};
} else {
next.push(nextItem);
}
return next;
};
const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress);
const hasVisibleOutput =
@@ -442,6 +479,38 @@ export const useAgentChatSession = ({
assistantMessageId,
appendArtifact,
});
} else if (event.type === "permission_request") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
permissions: upsertPermission(message.permissions, event),
}
: message,
),
);
} else if (event.type === "permission_response") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantMessageId || !message.permissions?.length) {
return message;
}
return {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === event.requestId
? {
...permission,
status: toPermissionStatus(event.reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
};
}),
);
} else if (event.type === "session_title") {
const nextTitle = event.title.trim();
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
@@ -647,6 +716,75 @@ export const useAgentChatSession = ({
cancelPromiseRef.current = trackedCancelPromise;
}, [getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => {
const target = messagesRef.current
.flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId);
if (!target || target.status === "submitting") {
return;
}
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? { ...permission, status: "submitting", error: undefined }
: permission,
),
},
),
);
try {
await replyAgentPermission(target.sessionId, requestId, reply);
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
error: undefined,
}
: permission,
),
},
),
);
} catch (error) {
setMessages((prev) =>
prev.map((message) =>
!message.permissions?.some((permission) => permission.requestId === requestId)
? message
: {
...message,
permissions: message.permissions.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: "error",
error: error instanceof Error ? error.message : String(error),
}
: permission,
),
},
),
);
}
},
[],
);
const createSession = useCallback(() => {
if (isHydrating || isStreaming) return;
@@ -982,6 +1120,7 @@ export const useAgentChatSession = ({
editAndResubmit,
cycleBranch,
abort,
replyPermission,
createSession,
renameSession,
removeSession,
+63 -6
View File
@@ -1,6 +1,8 @@
import {
abortAgentChat,
forkAgentChat,
replyAgentPermission,
type StreamEvent,
resumeAgentChatStream,
streamAgentChat,
} from "./chatStream";
@@ -162,12 +164,7 @@ describe("streamAgentChat", () => {
]),
});
const events: Array<{
type: string;
sessionId?: string;
tool?: string;
params?: Record<string, unknown>;
}> = [];
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
@@ -182,6 +179,43 @@ describe("streamAgentChat", () => {
});
});
it("parses permission request and response events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"metadata":{"command":"rm tmp.txt"},"always":["rm *"],"created_at":123}\n\n',
'event: permission_response\ndata: {"session_id":"s1","request_id":"perm-1","reply":"reject"}\n\n',
]),
});
const events: StreamEvent[] = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events).toEqual([
{
type: "permission_request",
sessionId: "s1",
requestId: "perm-1",
permission: "bash",
patterns: ["rm *"],
metadata: { command: "rm tmp.txt" },
always: ["rm *"],
tool: undefined,
createdAt: 123,
},
{
type: "permission_response",
sessionId: "s1",
requestId: "perm-1",
reply: "reject",
},
]);
});
it("emits error when response is not ok", async () => {
apiFetch.mockResolvedValue({
ok: false,
@@ -255,6 +289,29 @@ describe("streamAgentChat", () => {
);
});
it("calls permission reply endpoint", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await replyAgentPermission("s1", "perm-1", "once");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
reply: "once",
}),
}),
);
});
it("calls fork endpoint and returns new session id", async () => {
apiFetch.mockResolvedValue({
ok: true,
+89 -2
View File
@@ -5,6 +5,8 @@ export type AgentModel =
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro";
export type PermissionReply = "once" | "always" | "reject";
export type StreamEvent =
| {
type: "state";
@@ -41,6 +43,26 @@ export type StreamEvent =
sessionId: string;
tool: string;
params: Record<string, unknown>;
}
| {
type: "permission_request";
sessionId: string;
requestId: string;
permission: string;
patterns: string[];
metadata: Record<string, unknown>;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
}
| {
type: "permission_response";
sessionId: string;
requestId: string;
reply: PermissionReply;
};
type StreamOptions = {
@@ -111,7 +133,7 @@ const emitParsedStreamEvent = (
content?: string;
message?: string;
detail?: string;
tool?: string;
tool?: unknown;
params?: Record<string, unknown>;
arguments?: unknown;
id?: string;
@@ -126,6 +148,13 @@ const emitParsedStreamEvent = (
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
request_id?: string;
permission?: string;
patterns?: unknown;
metadata?: unknown;
always?: unknown;
created_at?: number;
reply?: PermissionReply;
};
if (event === "state") {
onEvent({
@@ -179,9 +208,39 @@ const emitParsedStreamEvent = (
onEvent({
type: "tool_call",
sessionId: parsed.session_id ?? "",
tool: parsed.tool ?? "",
tool: typeof parsed.tool === "string" ? parsed.tool : "",
params: resolveToolParams(parsed.params, parsed.arguments),
});
} else if (event === "permission_request") {
onEvent({
type: "permission_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
permission: parsed.permission ?? "",
patterns: Array.isArray(parsed.patterns)
? parsed.patterns.filter((item): item is string => typeof item === "string")
: [],
metadata: isObjectRecord(parsed.metadata) ? parsed.metadata : {},
always: Array.isArray(parsed.always)
? parsed.always.filter((item): item is string => typeof item === "string")
: [],
tool: isObjectRecord(parsed.tool) &&
typeof parsed.tool.messageID === "string" &&
typeof parsed.tool.callID === "string"
? {
messageID: parsed.tool.messageID,
callID: parsed.tool.callID,
}
: undefined,
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "permission_response") {
onEvent({
type: "permission_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
reply: parsed.reply ?? "reject",
});
}
} catch {
onEvent({
@@ -349,6 +408,34 @@ export const abortAgentChat = async (sessionId?: string) => {
}
};
export const replyAgentPermission = async (
sessionId: string,
requestId: string,
reply: PermissionReply,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
reply,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `permission reply failed: ${response.status}`);
}
};
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
method: "POST",