Update files
This commit is contained in:
@@ -135,6 +135,27 @@ APP_VARIANT=fast npx expo prebuild --platform android --clean --no-install
|
|||||||
adb install -r android/app/build/outputs/apk/debugOptimized/app-debugOptimized.apk
|
adb install -r android/app/build/outputs/apk/debugOptimized/app-debugOptimized.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Android dev + build scripts (Metro-enabled)
|
||||||
|
|
||||||
|
From repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run on phone (installs/launches variant and sets debug_http_host)
|
||||||
|
npm run android:fast
|
||||||
|
npm run android:debug
|
||||||
|
|
||||||
|
# Build APKs (for shipping over the wire)
|
||||||
|
npm run android:fast:build
|
||||||
|
npm run android:debug:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Both dev variants are Metro-enabled by default and set `debug_http_host` via adb.
|
||||||
|
- Default Metro endpoint is `localhost:8080`.
|
||||||
|
- If `localhost:8080` is not running but `localhost:8081` is, scripts auto-fallback to `localhost:8081`.
|
||||||
|
- For localhost endpoints, scripts also run `adb reverse tcp:<port> tcp:<port>` automatically.
|
||||||
|
- Override with `METRO_ENDPOINT=host:port` (for example `METRO_ENDPOINT=192.168.1.25:8081`).
|
||||||
|
|
||||||
## Testing with Playwright MCP
|
## Testing with Playwright MCP
|
||||||
|
|
||||||
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"android": "npm run android --workspace=@getpaseo/app",
|
"android": "npm run android --workspace=@getpaseo/app",
|
||||||
"android:fast": "npm run android:fast --workspace=@getpaseo/app",
|
"android:fast": "npm run android:fast --workspace=@getpaseo/app",
|
||||||
"android:debug": "npm run android:debug --workspace=@getpaseo/app",
|
"android:debug": "npm run android:debug --workspace=@getpaseo/app",
|
||||||
|
"android:fast:build": "npm run android:fast:build --workspace=@getpaseo/app",
|
||||||
|
"android:debug:build": "npm run android:debug:build --workspace=@getpaseo/app",
|
||||||
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
|
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
|
||||||
"android:release": "npm run android:prod --workspace=@getpaseo/app",
|
"android:release": "npm run android:prod --workspace=@getpaseo/app",
|
||||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "npm run android:fast",
|
"android": "npm run android:fast",
|
||||||
"android:fast": "APP_VARIANT=fast expo run:android --variant=debugOptimized",
|
"android:fast": "APP_VARIANT=fast bash ./scripts/android-run-variant.sh",
|
||||||
"android:debug": "APP_VARIANT=debug expo run:android --variant=debug",
|
"android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh",
|
||||||
|
"android:fast:build": "APP_VARIANT=fast bash ./scripts/android-build-variant.sh",
|
||||||
|
"android:debug:build": "APP_VARIANT=debug bash ./scripts/android-build-variant.sh",
|
||||||
"android:prod": "APP_VARIANT=prod expo run:android --variant=release",
|
"android:prod": "APP_VARIANT=prod expo run:android --variant=release",
|
||||||
"android:release": "npm run android:prod",
|
"android:release": "npm run android:prod",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
app_variant="${APP_VARIANT:-fast}"
|
||||||
|
|
||||||
|
case "$app_variant" in
|
||||||
|
fast)
|
||||||
|
gradle_task="app:assembleDebugOptimized"
|
||||||
|
apk_path="android/app/build/outputs/apk/debugOptimized/app-debugOptimized.apk"
|
||||||
|
;;
|
||||||
|
debug)
|
||||||
|
gradle_task="app:assembleDebug"
|
||||||
|
apk_path="android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "APP_VARIANT must be one of: fast, debug"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
APP_VARIANT="$app_variant" npx expo prebuild --platform android --clean --no-install
|
||||||
|
(cd android && ./gradlew "$gradle_task")
|
||||||
|
|
||||||
|
echo "Built APK: ${apk_path}"
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
app_variant="${APP_VARIANT:-fast}"
|
||||||
|
metro_endpoint="${METRO_ENDPOINT:-localhost:8080}"
|
||||||
|
launch_app="${LAUNCH_APP:-1}"
|
||||||
|
|
||||||
|
case "$app_variant" in
|
||||||
|
fast)
|
||||||
|
package_id="sh.paseo.dev"
|
||||||
|
app_name="Paseo Dev"
|
||||||
|
;;
|
||||||
|
debug)
|
||||||
|
package_id="sh.paseo.debug"
|
||||||
|
app_name="Paseo Debug"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "APP_VARIANT must be one of: fast, debug"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! [[ "$metro_endpoint" =~ ^[A-Za-z0-9._-]+:[0-9]{1,5}$ ]]; then
|
||||||
|
echo "Invalid METRO_ENDPOINT '$metro_endpoint'. Expected host:port (for example macbook:8081)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v adb >/dev/null 2>&1; then
|
||||||
|
echo "adb is not installed or not on PATH."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! adb get-state >/dev/null 2>&1; then
|
||||||
|
echo "No Android device detected by adb."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
metro_host="${metro_endpoint%:*}"
|
||||||
|
metro_port="${metro_endpoint##*:}"
|
||||||
|
|
||||||
|
if [[ "$metro_host" == "localhost" || "$metro_host" == "127.0.0.1" ]]; then
|
||||||
|
if ! lsof -nP -iTCP:"${metro_port}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
if [[ "$metro_port" == "8080" ]] && lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
metro_endpoint="localhost:8081"
|
||||||
|
metro_host="localhost"
|
||||||
|
metro_port="8081"
|
||||||
|
echo "Metro was not listening on localhost:8080; using localhost:8081."
|
||||||
|
else
|
||||||
|
echo "No Metro server detected on ${metro_endpoint}. Start Metro there or set METRO_ENDPOINT=host:port."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
adb reverse "tcp:${metro_port}" "tcp:${metro_port}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
prefs_file="${package_id}_preferences.xml"
|
||||||
|
prefs_path="shared_prefs/${prefs_file}"
|
||||||
|
prefs_xml="<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\" ?>
|
||||||
|
<map>
|
||||||
|
<string name=\"debug_http_host\">${metro_endpoint}</string>
|
||||||
|
</map>"
|
||||||
|
|
||||||
|
adb shell run-as "$package_id" mkdir -p shared_prefs
|
||||||
|
printf '%s\n' "$prefs_xml" | adb shell run-as "$package_id" tee "$prefs_path" >/dev/null
|
||||||
|
|
||||||
|
echo "Configured ${app_name} (${package_id}) to use Metro at ${metro_endpoint}."
|
||||||
|
|
||||||
|
if [[ "$launch_app" == "1" ]]; then
|
||||||
|
adb shell am force-stop "$package_id" >/dev/null 2>&1 || true
|
||||||
|
adb shell monkey -p "$package_id" -c android.intent.category.LAUNCHER 1 >/dev/null
|
||||||
|
echo "Launched ${app_name}."
|
||||||
|
fi
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
app_variant="${APP_VARIANT:-fast}"
|
||||||
|
metro_endpoint="${METRO_ENDPOINT:-localhost:8080}"
|
||||||
|
|
||||||
|
case "$app_variant" in
|
||||||
|
fast)
|
||||||
|
gradle_variant="debugOptimized"
|
||||||
|
;;
|
||||||
|
debug)
|
||||||
|
gradle_variant="debug"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "APP_VARIANT must be one of: fast, debug"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
APP_VARIANT="$app_variant" npx expo run:android --variant="$gradle_variant"
|
||||||
|
APP_VARIANT="$app_variant" METRO_ENDPOINT="$metro_endpoint" LAUNCH_APP=1 bash ./scripts/android-configure-metro-host.sh
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
Alert,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -425,15 +424,10 @@ export function AgentInputArea({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (voice.isVoiceModeForAgent(serverId, agentId)) {
|
if (voice.isVoiceModeForAgent(serverId, agentId)) {
|
||||||
void voice.stopVoice().catch((error) => {
|
|
||||||
console.error("[AgentInputArea] Failed to stop voice mode", error);
|
|
||||||
Alert.alert("Voice failed", "Unable to stop realtime voice mode.");
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
console.error("[AgentInputArea] Failed to start voice mode", error);
|
||||||
Alert.alert("Voice failed", "Unable to start realtime voice mode.");
|
|
||||||
});
|
});
|
||||||
}, [agentId, isConnected, serverId, voice]);
|
}, [agentId, isConnected, serverId, voice]);
|
||||||
|
|
||||||
@@ -543,29 +537,26 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<View style={styles.rightControls}>
|
<View style={styles.rightControls}>
|
||||||
<Pressable
|
{!isVoiceModeForAgent ? (
|
||||||
onPress={handleToggleRealtimeVoice}
|
<Pressable
|
||||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
onPress={handleToggleRealtimeVoice}
|
||||||
accessibilityLabel={
|
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||||
isVoiceModeForAgent ? "Disable realtime voice mode" : "Enable realtime voice mode"
|
accessibilityLabel="Enable realtime voice mode"
|
||||||
}
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
style={[
|
||||||
style={[
|
styles.realtimeVoiceButton as any,
|
||||||
styles.realtimeVoiceButton as any,
|
(!isConnected || voice?.isVoiceSwitching
|
||||||
isVoiceModeForAgent ? (styles.realtimeVoiceButtonActive as any) : undefined,
|
? styles.buttonDisabled
|
||||||
(!isConnected || voice?.isVoiceSwitching
|
: undefined) as any,
|
||||||
? styles.buttonDisabled
|
]}
|
||||||
: undefined) as any,
|
>
|
||||||
]}
|
{voice?.isVoiceSwitching ? (
|
||||||
>
|
<ActivityIndicator size="small" color="white" />
|
||||||
{voice?.isVoiceSwitching ? (
|
) : (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<AudioLines size={18} color={theme.colors.foreground} />
|
||||||
) : isVoiceModeForAgent ? (
|
)}
|
||||||
<Square size={16} color="white" fill="white" />
|
</Pressable>
|
||||||
) : (
|
) : null}
|
||||||
<AudioLines size={18} color={theme.colors.foreground} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
{cancelButton}
|
{cancelButton}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Pressable, View } from "react-native";
|
import { ActivityIndicator, Alert, Pressable, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, MicOff } from "lucide-react-native";
|
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||||
import { VolumeMeter } from "@/components/volume-meter";
|
import { VolumeMeter } from "@/components/volume-meter";
|
||||||
import { useVoice } from "@/contexts/voice-context";
|
import { useVoice } from "@/contexts/voice-context";
|
||||||
|
|
||||||
export function VoiceCompactIndicator() {
|
export function VoiceCompactIndicator() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { isVoiceMode, volume, isMuted, isDetecting, isSpeaking, toggleMute } =
|
const {
|
||||||
useVoice();
|
isVoiceMode,
|
||||||
|
isVoiceSwitching,
|
||||||
|
volume,
|
||||||
|
isMuted,
|
||||||
|
isDetecting,
|
||||||
|
isSpeaking,
|
||||||
|
toggleMute,
|
||||||
|
stopVoice,
|
||||||
|
} = useVoice();
|
||||||
|
|
||||||
if (!isVoiceMode) {
|
if (!isVoiceMode) {
|
||||||
return null;
|
return null;
|
||||||
@@ -26,19 +34,52 @@ export function VoiceCompactIndicator() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<View style={styles.controlsRow}>
|
||||||
onPress={toggleMute}
|
<Pressable
|
||||||
accessibilityRole="button"
|
onPress={toggleMute}
|
||||||
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
|
disabled={isVoiceSwitching}
|
||||||
style={styles.muteButton}
|
accessibilityRole="button"
|
||||||
hitSlop={8}
|
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
|
||||||
>
|
style={[
|
||||||
{isMuted ? (
|
styles.muteButton,
|
||||||
<MicOff size={14} color={theme.colors.palette.white} />
|
isVoiceSwitching ? styles.buttonDisabled : undefined,
|
||||||
) : (
|
]}
|
||||||
<Mic size={14} color={theme.colors.foreground} />
|
hitSlop={8}
|
||||||
)}
|
>
|
||||||
</Pressable>
|
{isMuted ? (
|
||||||
|
<MicOff size={14} color={theme.colors.palette.white} />
|
||||||
|
) : (
|
||||||
|
<Mic size={14} color={theme.colors.foreground} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
void stopVoice().catch((error) => {
|
||||||
|
console.error("[VoiceCompactIndicator] Failed to stop voice mode", error);
|
||||||
|
Alert.alert("Voice failed", "Unable to stop realtime voice mode.");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isVoiceSwitching}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Disable realtime voice mode"
|
||||||
|
style={[
|
||||||
|
styles.stopButton,
|
||||||
|
isVoiceSwitching ? styles.buttonDisabled : undefined,
|
||||||
|
]}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
{isVoiceSwitching ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.palette.white} />
|
||||||
|
) : (
|
||||||
|
<Square
|
||||||
|
size={14}
|
||||||
|
color={theme.colors.palette.white}
|
||||||
|
fill={theme.colors.palette.white}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,7 +88,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
paddingLeft: theme.spacing[3],
|
paddingLeft: theme.spacing[3],
|
||||||
paddingRight: theme.spacing[1],
|
paddingRight: theme.spacing[1],
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -63,6 +104,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
meterContainer: {
|
meterContainer: {
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
controlsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
muteButton: {
|
muteButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -72,4 +118,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
|
stopButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.palette.red[600],
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.palette.red[800],
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ describe("parseToolCallDisplay", () => {
|
|||||||
expect(display.toolName).toBe("Read");
|
expect(display.toolName).toBe("Read");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes tool names - paseo_voice.speak to Speak", () => {
|
||||||
|
const input = { text: "hello from namespaced speak" };
|
||||||
|
const display = parseToolCallDisplay("paseo_voice.speak", input, undefined);
|
||||||
|
expect(display.toolName).toBe("Speak");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes tool names - mcp__paseo_voice__speak to Speak", () => {
|
||||||
|
const input = { text: "hello from claude mcp speak" };
|
||||||
|
const display = parseToolCallDisplay("mcp__paseo_voice__speak", input, undefined);
|
||||||
|
expect(display.toolName).toBe("Speak");
|
||||||
|
});
|
||||||
|
|
||||||
test("preserves unknown tool names", () => {
|
test("preserves unknown tool names", () => {
|
||||||
const input = { some_arg: "value" };
|
const input = { some_arg: "value" };
|
||||||
const display = parseToolCallDisplay("MyCustomTool", input, undefined);
|
const display = parseToolCallDisplay("MyCustomTool", input, undefined);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { stripShellWrapperPrefix } from "@getpaseo/server/utils/tool-call-parsers";
|
import {
|
||||||
|
normalizeToolDisplayName,
|
||||||
|
stripShellWrapperPrefix,
|
||||||
|
} from "@getpaseo/server/utils/tool-call-parsers";
|
||||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "./perf";
|
import { getNowMs, isPerfLoggingEnabled, perfLog } from "./perf";
|
||||||
|
|
||||||
const TOOL_CALL_DIFF_LOG_TAG = "[ToolCallDiff]";
|
const TOOL_CALL_DIFF_LOG_TAG = "[ToolCallDiff]";
|
||||||
@@ -1203,7 +1206,9 @@ const ToolCallDisplaySchema = z
|
|||||||
result: z.unknown(),
|
result: z.unknown(),
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
const normalizedToolName = TOOL_NAME_MAP[data.toolName] ?? data.toolName;
|
const normalizedToolName = normalizeToolDisplayName(
|
||||||
|
TOOL_NAME_MAP[data.toolName] ?? data.toolName
|
||||||
|
);
|
||||||
|
|
||||||
// Handle thinking - input is the thinking text content
|
// Handle thinking - input is the thinking text content
|
||||||
if (data.toolName === "thinking") {
|
if (data.toolName === "thinking") {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import {
|
|||||||
generateStructuredAgentResponse,
|
generateStructuredAgentResponse,
|
||||||
} from "./agent/agent-response-loop.js";
|
} from "./agent/agent-response-loop.js";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPromptContentBlock,
|
AgentPromptContentBlock,
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
@@ -194,15 +193,10 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(
|
|||||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||||
const AgentIdSchema = z.string().uuid();
|
const AgentIdSchema = z.string().uuid();
|
||||||
const VOICE_MCP_SERVER_NAME = "paseo_voice";
|
const VOICE_MCP_SERVER_NAME = "paseo_voice";
|
||||||
const VOICE_MODE_ENFORCED_MODE_ID: Partial<Record<AgentProvider, string>> = {
|
|
||||||
claude: "default",
|
|
||||||
codex: "read-only",
|
|
||||||
};
|
|
||||||
|
|
||||||
type VoiceModeBaseConfig = {
|
type VoiceModeBaseConfig = {
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
modeId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AudioBufferState {
|
interface AudioBufferState {
|
||||||
@@ -1408,10 +1402,6 @@ export class Session {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getVoiceModeEnforcedModeId(provider: AgentProvider): string | undefined {
|
|
||||||
return VOICE_MODE_ENFORCED_MODE_ID[provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
||||||
const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent;
|
const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent;
|
||||||
if (!ensureVoiceSocket) {
|
if (!ensureVoiceSocket) {
|
||||||
@@ -1426,14 +1416,11 @@ export class Session {
|
|||||||
const baseConfig: VoiceModeBaseConfig = {
|
const baseConfig: VoiceModeBaseConfig = {
|
||||||
systemPrompt: existing.config.systemPrompt,
|
systemPrompt: existing.config.systemPrompt,
|
||||||
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
|
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
|
||||||
modeId: existing.config.modeId,
|
|
||||||
};
|
};
|
||||||
this.voiceModeBaseConfig = baseConfig;
|
this.voiceModeBaseConfig = baseConfig;
|
||||||
const voiceModeId = this.getVoiceModeEnforcedModeId(existing.config.provider);
|
|
||||||
const refreshOverrides: Partial<AgentSessionConfig> = {
|
const refreshOverrides: Partial<AgentSessionConfig> = {
|
||||||
systemPrompt: this.buildVoiceModeSystemPrompt(baseConfig.systemPrompt),
|
systemPrompt: this.buildVoiceModeSystemPrompt(baseConfig.systemPrompt),
|
||||||
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
|
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
|
||||||
...(voiceModeId ? { modeId: voiceModeId } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1475,7 +1462,6 @@ export class Session {
|
|||||||
await this.agentManager.refreshAgentFromPersistence(agentId, {
|
await this.agentManager.refreshAgentFromPersistence(agentId, {
|
||||||
systemPrompt: baseConfig.systemPrompt,
|
systemPrompt: baseConfig.systemPrompt,
|
||||||
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
|
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
|
||||||
modeId: baseConfig.modeId,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.warn(
|
this.sessionLogger.warn(
|
||||||
@@ -4658,10 +4644,6 @@ export class Session {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldAllowVoicePermission(request: AgentPermissionRequest): boolean {
|
|
||||||
return isVoicePermissionAllowed(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processWithVoiceAgent(
|
private async processWithVoiceAgent(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
userText: string
|
userText: string
|
||||||
@@ -4703,19 +4685,14 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.type === "permission_requested") {
|
if (event.type === "permission_requested") {
|
||||||
if (this.shouldAllowVoicePermission(event.request)) {
|
if (isVoicePermissionAllowed(event.request)) {
|
||||||
await this.agentManager.respondToPermission(agentId, event.request.id, {
|
await this.agentManager.respondToPermission(agentId, event.request.id, {
|
||||||
behavior: "allow",
|
behavior: "allow",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.agentManager.respondToPermission(agentId, event.request.id, {
|
this.sessionLogger.info(
|
||||||
behavior: "deny",
|
|
||||||
message: "Voice assistant policy only allows the speak tool.",
|
|
||||||
interrupt: true,
|
|
||||||
});
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{ agentId, requestName: event.request.name, requestId: event.request.id },
|
{ agentId, requestName: event.request.name, requestId: event.request.id },
|
||||||
"Voice assistant denied non-speak permission request"
|
"Voice assistant left non-speak permission request for user decision"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ describe("isVoicePermissionAllowed", () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows wrapper tools when metadata references speak", () => {
|
test("denies wrapper tools even when metadata references speak", () => {
|
||||||
const allowed = isVoicePermissionAllowed(
|
const denied = isVoicePermissionAllowed(
|
||||||
buildRequest({
|
buildRequest({
|
||||||
name: "codextool",
|
name: "codextool",
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -52,18 +52,6 @@ describe("isVoicePermissionAllowed", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(allowed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("denies codextool when metadata includes shell-like operations", () => {
|
|
||||||
const denied = isVoicePermissionAllowed(
|
|
||||||
buildRequest({
|
|
||||||
name: "codextool",
|
|
||||||
metadata: {
|
|
||||||
questions: [{ question: "Allow codextool to execute shell command?" }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(denied).toBe(false);
|
expect(denied).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,6 @@
|
|||||||
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
||||||
import { isSpeakToolName } from "./agent/tool-name-normalization.js";
|
import { isSpeakToolName } from "./agent/tool-name-normalization.js";
|
||||||
|
|
||||||
const SPEAK_TOKEN_SET = ["speak"];
|
|
||||||
const DENIED_TOKEN_SET = [
|
|
||||||
"bash",
|
|
||||||
"shell",
|
|
||||||
"terminal",
|
|
||||||
"apply_patch",
|
|
||||||
"edit",
|
|
||||||
"read_file",
|
|
||||||
"write_file",
|
|
||||||
"delete_file",
|
|
||||||
"web_search",
|
|
||||||
"fetch_url",
|
|
||||||
"create_agent",
|
|
||||||
"list_agents",
|
|
||||||
"kill_agent",
|
|
||||||
"wait_for_agent",
|
|
||||||
];
|
|
||||||
|
|
||||||
function containsAny(text: string, tokens: readonly string[]): boolean {
|
|
||||||
return tokens.some((token) => text.includes(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyMetadata(metadata: unknown): string {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(metadata ?? {})?.toLowerCase() ?? "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Voice assistant policy: only allow the speak tool. */
|
/** Voice assistant policy: only allow the speak tool. */
|
||||||
export function isVoicePermissionAllowed(request: AgentPermissionRequest): boolean {
|
export function isVoicePermissionAllowed(request: AgentPermissionRequest): boolean {
|
||||||
if (request.kind !== "tool") {
|
if (request.kind !== "tool") {
|
||||||
@@ -42,22 +12,5 @@ export function isVoicePermissionAllowed(request: AgentPermissionRequest): boole
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSpeakToolName(normalizedName)) {
|
return isSpeakToolName(normalizedName);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataText = stringifyMetadata({
|
|
||||||
name: request.name,
|
|
||||||
title: request.title ?? null,
|
|
||||||
description: request.description ?? null,
|
|
||||||
metadata: request.metadata ?? null,
|
|
||||||
input: request.input ?? null,
|
|
||||||
});
|
|
||||||
if (!metadataText) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionsAllowedTooling = containsAny(metadataText, SPEAK_TOKEN_SET);
|
|
||||||
const mentionsDeniedTooling = containsAny(metadataText, DENIED_TOKEN_SET);
|
|
||||||
return mentionsAllowedTooling && !mentionsDeniedTooling;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user