mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix hydrated tool call statuses and show tool outputs
This commit is contained in:
@@ -249,6 +249,8 @@ export function AgentStreamView({
|
||||
toolName={toolLabel}
|
||||
kind={data.kind}
|
||||
args={data.raw}
|
||||
result={data.result}
|
||||
error={data.error}
|
||||
status={data.status as "executing" | "completed" | "failed"}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,12 @@ import { baseColors, theme } from "@/styles/theme";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
extractReadEntries,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
|
||||
interface UserMessageProps {
|
||||
message: string;
|
||||
@@ -960,6 +966,26 @@ const toolKindIcons: Record<string, any> = {
|
||||
// Add more mappings as needed
|
||||
};
|
||||
|
||||
function formatPreviewValue(value: unknown, limit = 800): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
let text: string;
|
||||
if (typeof value === "string") {
|
||||
text = value.trim();
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, limit)}…`;
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
toolName,
|
||||
kind,
|
||||
@@ -969,6 +995,22 @@ export const ToolCall = memo(function ToolCall({
|
||||
status,
|
||||
onOpenDetails,
|
||||
}: ToolCallProps) {
|
||||
const editEntries = useMemo(() => extractEditEntries(args, result), [args, result]);
|
||||
const readEntries = useMemo(() => extractReadEntries(result, args), [args, result]);
|
||||
const commandDetails = useMemo(() => extractCommandDetails(args, result), [args, result]);
|
||||
|
||||
const primaryEditEntry = editEntries[0];
|
||||
const primaryReadEntry = readEntries[0];
|
||||
const genericResult =
|
||||
result !== undefined &&
|
||||
!commandDetails?.output &&
|
||||
!primaryReadEntry &&
|
||||
!primaryEditEntry
|
||||
? formatPreviewValue(result)
|
||||
: null;
|
||||
const formattedError =
|
||||
error !== undefined ? formatPreviewValue(error ?? null, 600) : null;
|
||||
|
||||
const spinAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1055,6 +1097,128 @@ export const ToolCall = memo(function ToolCall({
|
||||
{toolName}
|
||||
</Text>
|
||||
</View>
|
||||
{(commandDetails ||
|
||||
primaryEditEntry ||
|
||||
primaryReadEntry ||
|
||||
genericResult ||
|
||||
formattedError) && (
|
||||
<View style={toolCallStylesheet.expandedContent}>
|
||||
{commandDetails &&
|
||||
(commandDetails.command ||
|
||||
commandDetails.cwd ||
|
||||
commandDetails.exitCode !== undefined ||
|
||||
commandDetails.output) && (
|
||||
<View style={toolCallStylesheet.section}>
|
||||
<Text style={toolCallStylesheet.sectionTitle}>Command</Text>
|
||||
<View style={toolCallStylesheet.sectionContent}>
|
||||
{commandDetails.command && (
|
||||
<Text
|
||||
style={toolCallStylesheet.sectionText}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{commandDetails.command}
|
||||
</Text>
|
||||
)}
|
||||
{commandDetails.cwd && (
|
||||
<Text
|
||||
style={toolCallStylesheet.sectionText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{commandDetails.cwd}
|
||||
</Text>
|
||||
)}
|
||||
{commandDetails.exitCode !== undefined && (
|
||||
<Text style={toolCallStylesheet.sectionText}>
|
||||
Exit code:{" "}
|
||||
{commandDetails.exitCode === null
|
||||
? "Unknown"
|
||||
: commandDetails.exitCode}
|
||||
</Text>
|
||||
)}
|
||||
{commandDetails.output && (
|
||||
<Text
|
||||
style={toolCallStylesheet.sectionText}
|
||||
numberOfLines={6}
|
||||
>
|
||||
{formatPreviewValue(commandDetails.output)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{primaryReadEntry && (
|
||||
<View style={toolCallStylesheet.section}>
|
||||
<Text style={toolCallStylesheet.sectionTitle}>
|
||||
{primaryReadEntry.filePath
|
||||
? `Read: ${primaryReadEntry.filePath}`
|
||||
: "Read Output"}
|
||||
</Text>
|
||||
<View style={toolCallStylesheet.sectionContent}>
|
||||
<Text
|
||||
style={toolCallStylesheet.sectionText}
|
||||
numberOfLines={6}
|
||||
>
|
||||
{formatPreviewValue(primaryReadEntry.content)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{primaryEditEntry && (
|
||||
<View style={toolCallStylesheet.section}>
|
||||
<Text style={toolCallStylesheet.sectionTitle}>
|
||||
{primaryEditEntry.filePath
|
||||
? `Diff: ${primaryEditEntry.filePath}`
|
||||
: "Diff"}
|
||||
</Text>
|
||||
<View style={toolCallStylesheet.sectionContent}>
|
||||
<DiffViewer diffLines={primaryEditEntry.diffLines} maxHeight={160} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{genericResult && (
|
||||
<View style={toolCallStylesheet.section}>
|
||||
<Text style={toolCallStylesheet.sectionTitle}>Result</Text>
|
||||
<View style={toolCallStylesheet.sectionContent}>
|
||||
<Text style={toolCallStylesheet.sectionText} numberOfLines={6}>
|
||||
{genericResult}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{formattedError && (
|
||||
<View style={toolCallStylesheet.section}>
|
||||
<Text
|
||||
style={[
|
||||
toolCallStylesheet.sectionTitle,
|
||||
toolCallStylesheet.errorSectionTitle,
|
||||
]}
|
||||
>
|
||||
Error
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
toolCallStylesheet.sectionContent,
|
||||
toolCallStylesheet.errorSectionContent,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
toolCallStylesheet.sectionText,
|
||||
toolCallStylesheet.errorSectionTitle,
|
||||
]}
|
||||
numberOfLines={6}
|
||||
>
|
||||
{formattedError}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -221,7 +221,12 @@ function appendAgentToolCall(
|
||||
data: AgentToolCallData,
|
||||
timestamp: Date
|
||||
): StreamItem[] {
|
||||
const normalizedStatus = normalizeToolCallStatus(data.status);
|
||||
const normalizedStatus = normalizeToolCallStatus(
|
||||
data.status,
|
||||
data.result,
|
||||
data.error,
|
||||
data.raw
|
||||
);
|
||||
const callId = data.callId ?? extractToolCallId(data.raw);
|
||||
|
||||
const payloadData: AgentToolCallData = {
|
||||
@@ -293,20 +298,140 @@ function isPermissionToolCall(raw: unknown): boolean {
|
||||
return candidate.server === "permission" || candidate.kind === "permission";
|
||||
}
|
||||
|
||||
function normalizeToolCallStatus(status?: string): "executing" | "completed" | "failed" {
|
||||
const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/;
|
||||
const COMPLETED_STATUS_PATTERN =
|
||||
/complete|success|granted|applied|done|resolved|finish|succeed|ok/;
|
||||
|
||||
function normalizeStatusString(
|
||||
status?: string | null
|
||||
): "executing" | "completed" | "failed" | null {
|
||||
if (!status) {
|
||||
return "executing";
|
||||
return null;
|
||||
}
|
||||
const normalized = status.toLowerCase();
|
||||
if (/fail|error|deny|reject|cancel/.test(normalized)) {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (FAILED_STATUS_PATTERN.test(normalized)) {
|
||||
return "failed";
|
||||
}
|
||||
if (/complete|success|granted|applied|done|resolved/.test(normalized)) {
|
||||
if (COMPLETED_STATUS_PATTERN.test(normalized)) {
|
||||
return "completed";
|
||||
}
|
||||
return "executing";
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function inferStatusFromRaw(raw: unknown): "completed" | "failed" | null {
|
||||
if (!hasValue(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue: unknown[] = Array.isArray(raw) ? [...raw] : [raw];
|
||||
const visited = new Set<object>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const candidate = queue.shift();
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (visited.has(candidate as object)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(candidate as object);
|
||||
const record = candidate as Record<string, unknown>;
|
||||
|
||||
if (record.is_error === true) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const statusValue = normalizeStatusString(
|
||||
typeof record.status === "string" ? record.status : undefined
|
||||
);
|
||||
if (statusValue === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (statusValue === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
if ("error" in record && hasValue(record.error)) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
if (typeof record.stderr === "string" && record.stderr.length > 0) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const typeValue =
|
||||
typeof record.type === "string" ? record.type.toLowerCase() : "";
|
||||
if (typeValue) {
|
||||
if (FAILED_STATUS_PATTERN.test(typeValue)) {
|
||||
return "failed";
|
||||
}
|
||||
if (/result|response|output|success/.test(typeValue)) {
|
||||
return "completed";
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode =
|
||||
typeof record.exitCode === "number"
|
||||
? record.exitCode
|
||||
: typeof record.exit_code === "number"
|
||||
? record.exit_code
|
||||
: null;
|
||||
if (exitCode !== null) {
|
||||
return exitCode === 0 ? "completed" : "failed";
|
||||
}
|
||||
|
||||
const successValue =
|
||||
typeof record.success === "boolean" ? record.success : null;
|
||||
if (successValue !== null) {
|
||||
return successValue ? "completed" : "failed";
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
queue.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeToolCallStatus(
|
||||
status?: string,
|
||||
result?: unknown,
|
||||
error?: unknown,
|
||||
raw?: unknown
|
||||
): "executing" | "completed" | "failed" {
|
||||
const normalizedFromStatus = normalizeStatusString(status);
|
||||
if (normalizedFromStatus === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (normalizedFromStatus === "completed") {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
if (hasValue(error)) {
|
||||
return "failed";
|
||||
}
|
||||
if (hasValue(result)) {
|
||||
return "completed";
|
||||
}
|
||||
|
||||
const inferredFromRaw = inferStatusFromRaw(raw);
|
||||
if (inferredFromRaw) {
|
||||
return inferredFromRaw;
|
||||
}
|
||||
|
||||
return normalizedFromStatus ?? "executing";
|
||||
}
|
||||
|
||||
const TOOL_CALL_ID_KEYS = [
|
||||
"toolCallId",
|
||||
"tool_call_id",
|
||||
|
||||
11
plan.md
11
plan.md
@@ -17,6 +17,11 @@
|
||||
- Updated the agent overflow menu label in `packages/app/src/app/agent/[id].tsx` so the refresh action now matches the desired wording while keeping the busy state text untouched; no additional changes were required.
|
||||
- [x] Are we filtering our own shats (already present in agents storage) from the resume agent list? We should if not.
|
||||
- Resume tab now filters out persisted sessions whose session ids match any active agent (live session id or persisted handle) to avoid duplicate entries; verified via `npm run typecheck --workspace=@voice-dev/app`.
|
||||
- [ ] Getting "two children with the same key" for "thoughts" and "assistant" review our keying strategy, and make it more robust and performant, and stable.
|
||||
- [ ] Hydrated session show previous tool calls as loading. At least for claude we're not loading the output Chekc Codex too.
|
||||
- [ ] Add agent type indicator in the agent list, so we can quickly identify the agent type (Claude, Codex, etc.). On the left of the status pill.
|
||||
- [x] Getting "two children with the same key" for "thoughts" and "assistant" review our keying strategy, and make it more robust and performant, and stable.
|
||||
- Added deterministic per-entry suffixes when creating assistant and thought timeline ids so FlatList keys remain unique even when providers replay identical text chunks with the same timestamps; reran `npm run typecheck --workspace=@voice-dev/app`.
|
||||
- [x] Hydrated session show previous tool calls as loading. At least for claude we're not loading the output Chekc Codex too.
|
||||
- Tool call snapshots now infer completed/failed states when historical events lacked an explicit status, and we accumulate every tool payload in `raw` so hydrated sessions expose prior diffs/reads/command output instead of staying in a loading state. Added regression coverage in `test-idempotent-stream.ts` and ran `npm run typecheck --workspace=@voice-dev/app`.
|
||||
- [x] Add agent type indicator in the agent list, so we can quickly identify the agent type (Claude, Codex, etc.). On the left of the status pill.
|
||||
- Agent cards now include a provider badge left of the status pill by pulling provider labels from the manifest, with new styles to match the sidebar treatment; ran `npm run typecheck --workspace=@voice-dev/app`.
|
||||
- [x] Hydrated agents still show loading state for tool calls, check this properly, it's not fixed. I am also not seeing the tool call output in the agent stream, which is important.
|
||||
- Tool snapshots now infer completed/failed states by walking the raw payload (exit codes, tool_result/error flags) when status/result/error are missing, and added regression coverage in `test-idempotent-stream.ts`. The agent stream cards now render command output/read content/diff previews inline plus show failures, and we pass result/error data through so hydrated tool calls immediately display their output. Verified with `npm run typecheck --workspace=@voice-dev/app` and `npx tsx test-idempotent-stream.ts`.
|
||||
|
||||
@@ -253,9 +253,143 @@ function testToolCallInputPreservation() {
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Assistant message chunks should preserve whitespace between words
|
||||
// Test 5: Completed tool calls without status should infer completion for hydrated state
|
||||
function testToolCallStatusInference() {
|
||||
console.log('\n=== Test 5: Tool Call Status Inference ===');
|
||||
|
||||
const toolCallId = 'tool-completion';
|
||||
const timestamp1 = new Date('2025-01-01T10:10:00Z');
|
||||
const timestamp2 = new Date('2025-01-01T10:10:05Z');
|
||||
|
||||
const startEvent: AgentStreamEventPayload = {
|
||||
type: 'timeline',
|
||||
provider: 'claude',
|
||||
item: {
|
||||
type: 'tool_call',
|
||||
server: 'editor',
|
||||
tool: 'read',
|
||||
status: 'pending',
|
||||
callId: toolCallId,
|
||||
raw: { type: 'tool_use', tool_use_id: toolCallId, input: { file_path: 'README.md' } },
|
||||
},
|
||||
};
|
||||
|
||||
const completionEvent: AgentStreamEventPayload = {
|
||||
type: 'timeline',
|
||||
provider: 'claude',
|
||||
item: {
|
||||
type: 'tool_call',
|
||||
server: 'editor',
|
||||
tool: 'read',
|
||||
callId: toolCallId,
|
||||
raw: { type: 'tool_result', tool_use_id: toolCallId, output: { content: 'Hello world' } },
|
||||
output: { content: 'Hello world' },
|
||||
},
|
||||
};
|
||||
|
||||
const state = hydrateStreamState([
|
||||
{ event: startEvent, timestamp: timestamp1 },
|
||||
{ event: completionEvent, timestamp: timestamp2 },
|
||||
]);
|
||||
|
||||
const toolEntry = state.find(
|
||||
(item): item is ToolCallItem =>
|
||||
item.kind === 'tool_call' && item.payload.source === 'agent' && item.payload.data.callId === toolCallId
|
||||
);
|
||||
|
||||
if (
|
||||
toolEntry &&
|
||||
toolEntry.payload.data.status === 'completed' &&
|
||||
toolEntry.payload.data.result &&
|
||||
(toolEntry.payload.data.result as { content?: string }).content === 'Hello world'
|
||||
) {
|
||||
console.log('✅ PASS: Missing status inferred from output and completion payload kept');
|
||||
} else {
|
||||
console.log('❌ FAIL: Expected inferred completion status and output');
|
||||
console.log('State:', JSON.stringify(state, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5b: Completed tool calls should also infer status from raw payload
|
||||
function testToolCallStatusInferenceFromRawOnly() {
|
||||
console.log('\n=== Test 5b: Tool Call Status From Raw Payload ===');
|
||||
|
||||
const toolCallId = 'raw-status';
|
||||
const timestamp = new Date('2025-01-01T10:20:00Z');
|
||||
|
||||
const rawEvent: AgentStreamEventPayload = {
|
||||
type: 'timeline',
|
||||
provider: 'claude',
|
||||
item: {
|
||||
type: 'tool_call',
|
||||
server: 'command',
|
||||
tool: 'shell',
|
||||
callId: toolCallId,
|
||||
raw: {
|
||||
type: 'mcp_tool_result',
|
||||
tool_use_id: toolCallId,
|
||||
result: { metadata: { exit_code: 0 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = hydrateStreamState([{ event: rawEvent, timestamp }]);
|
||||
const toolEntry = state.find(
|
||||
(item): item is ToolCallItem =>
|
||||
item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
|
||||
if (toolEntry?.payload.data.status === 'completed') {
|
||||
console.log('✅ PASS: Raw payload exit code inferred completion');
|
||||
} else {
|
||||
console.log('❌ FAIL: Expected completed status inferred from raw payload');
|
||||
console.log('State:', JSON.stringify(state, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5c: Tool call failures should be inferred from raw payload errors
|
||||
function testToolCallFailureInferenceFromRaw() {
|
||||
console.log('\n=== Test 5c: Tool Call Failure From Raw Payload ===');
|
||||
|
||||
const toolCallId = 'raw-error';
|
||||
const timestamp = new Date('2025-01-01T10:25:00Z');
|
||||
|
||||
const rawEvent: AgentStreamEventPayload = {
|
||||
type: 'timeline',
|
||||
provider: 'claude',
|
||||
item: {
|
||||
type: 'tool_call',
|
||||
server: 'command',
|
||||
tool: 'shell',
|
||||
callId: toolCallId,
|
||||
raw: {
|
||||
type: 'mcp_tool_result',
|
||||
tool_use_id: toolCallId,
|
||||
is_error: true,
|
||||
error: {
|
||||
message: 'Command failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = hydrateStreamState([{ event: rawEvent, timestamp }]);
|
||||
const toolEntry = state.find(
|
||||
(item): item is ToolCallItem =>
|
||||
item.kind === 'tool_call' && item.payload.source === 'agent'
|
||||
);
|
||||
|
||||
if (toolEntry?.payload.data.status === 'failed') {
|
||||
console.log('✅ PASS: Raw payload error inferred failure');
|
||||
} else {
|
||||
console.log('❌ FAIL: Expected failed status inferred from raw payload');
|
||||
console.log('State:', JSON.stringify(state, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Assistant message chunks should preserve whitespace between words
|
||||
function testAssistantWhitespacePreservation() {
|
||||
console.log('\n=== Test 5: Assistant Message Whitespace Preservation ===');
|
||||
console.log('\n=== Test 6: Assistant Message Whitespace Preservation ===');
|
||||
|
||||
const timestamp = new Date('2025-01-01T11:00:00Z');
|
||||
|
||||
@@ -276,9 +410,9 @@ function testAssistantWhitespacePreservation() {
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: User messages should persist through hydration and deduplicate with live events
|
||||
// Test 7: User messages should persist through hydration and deduplicate with live events
|
||||
function testUserMessageHydration() {
|
||||
console.log('\n=== Test 6: User Message Hydration ===');
|
||||
console.log('\n=== Test 7: User Message Hydration ===');
|
||||
|
||||
const timestamp = new Date('2025-01-01T11:30:00Z');
|
||||
const messageId = 'msg_user_1';
|
||||
@@ -316,9 +450,9 @@ function testUserMessageHydration() {
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Permission tool calls should not show in the timeline
|
||||
// Test 8: Permission tool calls should not show in the timeline
|
||||
function testPermissionToolCallFiltering() {
|
||||
console.log('\n=== Test 7: Permission Tool Call Filtering ===');
|
||||
console.log('\n=== Test 8: Permission Tool Call Filtering ===');
|
||||
|
||||
const timestamp = new Date('2025-01-01T12:00:00Z');
|
||||
const updates = [
|
||||
@@ -339,9 +473,9 @@ function testPermissionToolCallFiltering() {
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: Todo lists should consolidate into a single entry and update completions
|
||||
// Test 9: Todo lists should consolidate into a single entry and update completions
|
||||
function testTodoListConsolidation() {
|
||||
console.log('\n=== Test 8: Todo List Consolidation ===');
|
||||
console.log('\n=== Test 9: Todo List Consolidation ===');
|
||||
|
||||
const timestamp1 = new Date('2025-01-01T12:30:00Z');
|
||||
const timestamp2 = new Date('2025-01-01T12:31:00Z');
|
||||
@@ -387,6 +521,9 @@ testIdempotentReduction();
|
||||
testUserMessageDeduplication();
|
||||
testMultipleMessages();
|
||||
testToolCallInputPreservation();
|
||||
testToolCallStatusInference();
|
||||
testToolCallStatusInferenceFromRawOnly();
|
||||
testToolCallFailureInferenceFromRaw();
|
||||
testAssistantWhitespacePreservation();
|
||||
testUserMessageHydration();
|
||||
testPermissionToolCallFiltering();
|
||||
|
||||
Reference in New Issue
Block a user