feat: add permission request UI
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user