添加新的工具功能和测试覆盖:

- 注册 `LsTool` 和 `BashTool` 工具,支持目录操作和命令执行
- 增强工具调用逻辑,添加日志记录以提升调试能力
- 增加 `ToolRegistry` 和 `RunLoop` 的增量累积与排序优化
- 完善单元测试覆盖新工具的执行与行为验证
This commit is contained in:
2025-12-23 17:26:27 +08:00
parent 78875ec3eb
commit 71226c255b
11 changed files with 721 additions and 25 deletions

189
tests/Unit/LsToolTest.php Normal file
View File

@@ -0,0 +1,189 @@
<?php
namespace Tests\Unit;
use App\Services\Tool\Tools\LsTool;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class LsToolTest extends TestCase
{
private string $tempDirectory;
protected function setUp(): void
{
parent::setUp();
$base = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
$this->tempDirectory = $base.DIRECTORY_SEPARATOR.'ls-tool-'.bin2hex(random_bytes(8));
if (! mkdir($this->tempDirectory, 0777, true) && ! is_dir($this->tempDirectory)) {
$this->fail('无法创建临时目录:'.$this->tempDirectory);
}
file_put_contents($this->tempDirectory.DIRECTORY_SEPARATOR.'a.txt', 'a');
file_put_contents($this->tempDirectory.DIRECTORY_SEPARATOR.'b.php', '<?php echo 1;');
file_put_contents($this->tempDirectory.DIRECTORY_SEPARATOR.'.env', 'KEY=VALUE');
mkdir($this->tempDirectory.DIRECTORY_SEPARATOR.'dir', 0777, true);
}
protected function tearDown(): void
{
$this->deleteDirectory($this->tempDirectory);
parent::tearDown();
}
public function test_execute_lists_entries_in_directory(): void
{
$tool = new LsTool;
$result = $tool->execute(['directory' => $this->tempDirectory]);
$this->assertSame($this->tempDirectory, $result['directory']);
$this->assertSame(['.env', 'a.txt', 'b.php', 'dir'], $result['entries']);
}
public function test_execute_can_exclude_hidden_entries(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'include_hidden' => false,
]);
$this->assertSame(['a.txt', 'b.php', 'dir'], $result['entries']);
}
public function test_execute_can_filter_files_only(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'filter' => 'files',
]);
$this->assertSame(['.env', 'a.txt', 'b.php'], $result['entries']);
}
public function test_execute_can_filter_directories_only(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'filter' => 'directories',
]);
$this->assertSame(['dir'], $result['entries']);
}
public function test_execute_can_match_glob_pattern(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'match' => '*.php',
]);
$this->assertSame(['b.php'], $result['entries']);
}
public function test_execute_can_return_details(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'details' => true,
]);
$this->assertCount(4, $result['entries']);
$fileEntry = $this->findEntry($result['entries'], 'a.txt');
$this->assertSame('file', $fileEntry['type']);
$this->assertIsInt($fileEntry['size']);
$this->assertIsInt($fileEntry['modified_at']);
$dirEntry = $this->findEntry($result['entries'], 'dir');
$this->assertSame('directory', $dirEntry['type']);
$this->assertNull($dirEntry['size']);
}
public function test_execute_can_sort_by_mtime_desc(): void
{
$mtimeDirectory = $this->tempDirectory.DIRECTORY_SEPARATOR.'mtime';
mkdir($mtimeDirectory, 0777, true);
$older = $mtimeDirectory.DIRECTORY_SEPARATOR.'older.txt';
$newer = $mtimeDirectory.DIRECTORY_SEPARATOR.'newer.txt';
file_put_contents($older, 'old');
file_put_contents($newer, 'new');
$now = time();
touch($older, $now - 10);
touch($newer, $now - 5);
$tool = new LsTool;
$result = $tool->execute([
'directory' => $mtimeDirectory,
'include_hidden' => false,
'sort' => 'mtime_desc',
'filter' => 'files',
'match' => '*.txt',
]);
$this->assertSame(['newer.txt', 'older.txt'], $result['entries']);
}
public function test_execute_throws_when_directory_is_invalid(): void
{
$this->expectException(InvalidArgumentException::class);
$tool = new LsTool;
$tool->execute(['directory' => $this->tempDirectory.DIRECTORY_SEPARATOR.'missing']);
}
/**
* @param array<int, array<string, mixed>> $entries
* @return array<string, mixed>
*/
private function findEntry(array $entries, string $name): array
{
foreach ($entries as $entry) {
if (($entry['name'] ?? null) === $name) {
return $entry;
}
}
$this->fail('未找到条目:'.$name);
}
private function deleteDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
$items = scandir($directory);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $directory.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
$this->deleteDirectory($path);
continue;
}
@unlink($path);
}
@rmdir($directory);
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Tests\Unit;
use App\Services\RunLoop;
use ReflectionClass;
use Tests\TestCase;
class RunLoopToolCallTest extends TestCase
{
/**
* 测试流式 tool call 增量正确累积(模拟 OpenAI 流式 API 行为)
*
* OpenAI 流式 API 返回 tool call 时:
* - 第一个 chunk 包含 id、name、index
* - 后续 chunks 只包含 arguments 增量和 index id
*/
public function test_accumulate_tool_calls_with_streaming_chunks(): void
{
$buffer = [];
$order = [];
// 模拟 OpenAI 流式返回的 tool call chunks
$chunks = [
// 第一个 chunk包含 id、name、index
[['id' => 'tooluse_ABC123', 'name' => 'ls', 'arguments' => '', 'index' => 0]],
// 后续 chunks只有 arguments 增量和 index无 id
[['id' => null, 'name' => null, 'arguments' => '{"direc', 'index' => 0]],
[['id' => null, 'name' => null, 'arguments' => 'tory', 'index' => 0]],
[['id' => null, 'name' => null, 'arguments' => '": "/h', 'index' => 0]],
[['id' => null, 'name' => null, 'arguments' => 'ome"}', 'index' => 0]],
];
foreach ($chunks as $toolCalls) {
$this->invokeAccumulateToolCalls($buffer, $order, $toolCalls);
}
// 验证只有一个 tool call
$this->assertCount(1, $buffer);
$this->assertArrayHasKey(0, $buffer);
// 验证 id 和 name 正确保留
$this->assertSame('tooluse_ABC123', $buffer[0]['id']);
$this->assertSame('ls', $buffer[0]['name']);
// 验证 arguments 正确拼接
$this->assertSame('{"directory": "/home"}', $buffer[0]['arguments']);
}
/**
* 测试多个并行 tool calls 的累积
*/
public function test_accumulate_multiple_parallel_tool_calls(): void
{
$buffer = [];
$order = [];
// 模拟两个并行的 tool calls
$chunks = [
// 第一个 tool call 的第一个 chunk
[['id' => 'call_1', 'name' => 'ls', 'arguments' => '', 'index' => 0]],
// 第二个 tool call 的第一个 chunk
[['id' => 'call_2', 'name' => 'cat', 'arguments' => '', 'index' => 1]],
// 第一个 tool call 的 arguments
[['id' => null, 'name' => null, 'arguments' => '{"path": "/home"}', 'index' => 0]],
// 第二个 tool call 的 arguments
[['id' => null, 'name' => null, 'arguments' => '{"file": "test.txt"}', 'index' => 1]],
];
foreach ($chunks as $toolCalls) {
$this->invokeAccumulateToolCalls($buffer, $order, $toolCalls);
}
// 验证有两个 tool calls
$this->assertCount(2, $buffer);
// 验证第一个 tool call
$this->assertSame('call_1', $buffer[0]['id']);
$this->assertSame('ls', $buffer[0]['name']);
$this->assertSame('{"path": "/home"}', $buffer[0]['arguments']);
// 验证第二个 tool call
$this->assertSame('call_2', $buffer[1]['id']);
$this->assertSame('cat', $buffer[1]['name']);
$this->assertSame('{"file": "test.txt"}', $buffer[1]['arguments']);
}
/**
* 测试 finalizeToolCalls 正确整理结果
*/
public function test_finalize_tool_calls(): void
{
$buffer = [
0 => ['id' => 'call_1', 'name' => 'ls', 'arguments' => '{"path": "/"}', 'index' => 0],
1 => ['id' => 'call_2', 'name' => 'cat', 'arguments' => '{"file": "a.txt"}', 'index' => 1],
];
$order = [0 => 0, 1 => 1];
$result = $this->invokeFinalizeToolCalls($buffer, $order, 'tool_calls');
$this->assertCount(2, $result);
$this->assertSame('call_1', $result[0]['id']);
$this->assertSame('ls', $result[0]['name']);
$this->assertSame('{"path": "/"}', $result[0]['arguments']);
$this->assertSame('tool_calls', $result[0]['finish_reason']);
$this->assertSame('call_2', $result[1]['id']);
$this->assertSame('cat', $result[1]['name']);
}
/**
* 测试空 buffer 返回空数组
*/
public function test_finalize_empty_buffer(): void
{
$result = $this->invokeFinalizeToolCalls([], [], 'stop');
$this->assertSame([], $result);
}
/**
* 测试缺少 index 时默认使用 0
*/
public function test_accumulate_without_index_defaults_to_zero(): void
{
$buffer = [];
$order = [];
$this->invokeAccumulateToolCalls($buffer, $order, [
['id' => 'call_1', 'name' => 'test', 'arguments' => '{}'],
]);
$this->assertCount(1, $buffer);
$this->assertArrayHasKey(0, $buffer);
$this->assertSame('call_1', $buffer[0]['id']);
}
private function invokeAccumulateToolCalls(array &$buffer, array &$order, array $toolCalls): void
{
$runLoop = $this->createRunLoopMock();
$method = (new ReflectionClass(RunLoop::class))->getMethod('accumulateToolCalls');
$method->setAccessible(true);
$method->invokeArgs($runLoop, [&$buffer, &$order, $toolCalls]);
}
private function invokeFinalizeToolCalls(array $buffer, array $order, ?string $doneReason): array
{
$runLoop = $this->createRunLoopMock();
$method = (new ReflectionClass(RunLoop::class))->getMethod('finalizeToolCalls');
$method->setAccessible(true);
return $method->invokeArgs($runLoop, [$buffer, $order, $doneReason]);
}
private function createRunLoopMock(): RunLoop
{
return $this->getMockBuilder(RunLoop::class)
->disableOriginalConstructor()
->getMock();
}
}