From e32823e4b584e704c306e162ffbbd3cecfb0b064 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 8 Jun 2026 13:32:50 +0800 Subject: [PATCH] feat: add permission request UI --- src/components/chat/AgentTurn.tsx | 601 ++++++++++++++++++ src/components/chat/AgentWorkspace.test.tsx | 1 + src/components/chat/AgentWorkspace.tsx | 12 +- src/components/chat/GlobalChatbox.tsx | 2 + src/components/chat/GlobalChatbox.types.ts | 26 + .../chat/hooks/useAgentChatSession.test.tsx | 68 +- .../chat/hooks/useAgentChatSession.ts | 141 +++- src/lib/chatStream.test.ts | 69 +- src/lib/chatStream.ts | 91 ++- 9 files changed, 999 insertions(+), 12 deletions(-) diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 03de6ce..ab13f01 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -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 }) => ( ); +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) => { + 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[number]) => { + if (permission.permission === "external_directory") return "访问工作区外目录"; + if (permission.permission === "bash") return "执行终端命令"; + if (permission.permission === "edit") return "修改文件内容"; + return permission.permission || "工具权限请求"; +}; + +const getPermissionPrimaryValue = ( + permission: NonNullable[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[number]; +}) => { + if (permission.permission === "bash") { + return ; + } + if (permission.permission === "external_directory") { + return ; + } + return ; +}; + +const getPermissionStatusLabel = (status: NonNullable[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[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 ( + + + + + + + + {getPermissionTitle(permission)} + + + + + + + + + 请求目标 + + + {primaryValue} + + + + {metadataText ? ( + + {metadataText} + + ) : null} + + + {permission.error ? ( + + + {permission.error} + + + ) : null} + + {isPending || isSubmitting ? ( + + + + + + ) : null} + + ); +}; + +const PermissionRequestGroup = ({ + permissions, + onReply, +}: { + permissions: NonNullable; + 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 ( + + 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) }, + }} + > + + + + + + 权限请求 + + + {summaryItems.map((item) => ( + + + {item.label} + + {item.value} 项 + + ))} + + + + + {expanded ? ( + + ) : ( + + )} + + + + {!expanded && !hasPendingPermissions && latestPermissions.length > 0 ? ( + + {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 ( + + + + + + + {getPermissionTitle(permission)} + + + {truncateText(primaryValue, 72)} + + + + + ); + })} + + ) : null} + + + {!expanded && hasPendingPermissions ? ( + + + {pendingPermissions.map((permission) => ( + + ))} + + + ) : null} + + + + + {permissions.map((permission) => ( + + ))} + + + + ); +}; + 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( ) : null} + {message.permissions?.length ? ( + + ) : null} + { onRegenerate: jest.fn(), onEditResubmit: jest.fn(), onCycleBranch: jest.fn(), + onReplyPermission: jest.fn(), }; beforeEach(() => { diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 5f3e03f..ba15fa8 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -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(); @@ -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} /> diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index eb81823..a020a5f 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -75,6 +75,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { editAndResubmit, cycleBranch, abort, + replyPermission, createSession, renameSession, removeSession, @@ -354,6 +355,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { onRegenerate={regenerate} onEditResubmit={editAndResubmit} onCycleBranch={cycleBranch} + onReplyPermission={replyPermission} /> ; }; +export type AgentPermissionStatus = + | "pending" + | "submitting" + | "approved_once" + | "approved_always" + | "rejected" + | "error"; + +export type AgentPermissionRequest = { + requestId: string; + sessionId: string; + permission: string; + patterns: string[]; + metadata: Record; + 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; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 8e3d84d..d02735f 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -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(() => 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")); }); }), diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 7694f6e..31e814a 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -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, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 64a6c62..6be9065 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -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; - }> = []; + 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, diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index a6581e5..5bbdc35 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -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; + } + | { + type: "permission_request"; + sessionId: string; + requestId: string; + permission: string; + patterns: string[]; + metadata: Record; + 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; 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",