添加新的工具功能和测试覆盖:
- 注册 `LsTool` 和 `BashTool` 工具,支持目录操作和命令执行 - 增强工具调用逻辑,添加日志记录以提升调试能力 - 增加 `ToolRegistry` 和 `RunLoop` 的增量累积与排序优化 - 完善单元测试覆盖新工具的执行与行为验证
This commit is contained in:
11
.ai/mcp/mcp.json
Normal file
11
.ai/mcp/mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "vendor/bin/sail",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"php-lsp@claude-plugins-official": true,
|
||||
"context7@claude-plugins-official": true,
|
||||
"laravel-boost@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数
|
||||
AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒)
|
||||
|
||||
# Tool 子 Run 调度与超时
|
||||
AGENT_TOOL_MAX_CALLS_PER_RUN=1 # 单个父 Run 允许的工具调用次数
|
||||
AGENT_TOOL_MAX_CALLS_PER_RUN=99 # 单个父 Run 允许的工具调用次数
|
||||
AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒)
|
||||
AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒)
|
||||
AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT)
|
||||
|
||||
1
.qoderignore
Normal file
1
.qoderignore
Normal file
@@ -0,0 +1 @@
|
||||
Specify files or folders to ignore during indexing. Use commas to separate entries. Glob patterns like *.log,my-security/ are supported.
|
||||
@@ -36,6 +36,8 @@ class ToolRunJob implements ShouldQueue
|
||||
{
|
||||
$call = ToolCall::fromArray($this->toolCall);
|
||||
|
||||
logger('ToolRunJob call:', $call->toArray());
|
||||
|
||||
if ($cancelChecker->isCanceled($this->sessionId, $call->parentRunId)) {
|
||||
$sink->appendRunStatus($this->sessionId, $call->runId, 'CANCELED', [
|
||||
'dedupe_key' => "run:{$call->runId}:status:CANCELED",
|
||||
|
||||
@@ -70,6 +70,8 @@ class RunLoop
|
||||
$toolCallCount
|
||||
);
|
||||
|
||||
logger('agent provider iteration', [$iterationResult]);
|
||||
|
||||
// 2.3 处理失败或取消
|
||||
if ($iterationResult['should_exit']) {
|
||||
return;
|
||||
@@ -261,7 +263,7 @@ class RunLoop
|
||||
$streamState = $iterationResult['stream_state'];
|
||||
$latencyMs = $iterationResult['latency_ms'];
|
||||
$updatedToolCount = $iterationResult['updated_tool_count'];
|
||||
|
||||
logger('agent tool calls', [$streamState, $latencyMs, $updatedToolCount]);
|
||||
// 1. 检查工具调用数量是否超限
|
||||
if ($updatedToolCount > $this->maxToolCalls) {
|
||||
$this->appendProviderFailure(
|
||||
@@ -605,47 +607,55 @@ class RunLoop
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具增量收集:同一个 tool_call_id 可能多次分片返回,此处拼接参数与名称。
|
||||
* 工具增量收集:同一个 tool call 通过 index 关联,多次分片返回时拼接参数与名称。
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $buffer
|
||||
* @param array<string, int> $order
|
||||
* OpenAI 流式 API 中,tool call 的第一个 chunk 包含 id、name、index,
|
||||
* 后续 chunks 只包含 arguments 增量和 index(无 id)。
|
||||
* 因此必须使用 index 作为 buffer 的 key 来正确累积。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $buffer 以 index 为 key 的缓冲区
|
||||
* @param array<int, int> $order 记录 index 出现顺序
|
||||
* @param array<int, array<string, mixed>> $toolCalls
|
||||
*/
|
||||
private function accumulateToolCalls(array &$buffer, array &$order, array $toolCalls): void
|
||||
{
|
||||
foreach ($toolCalls as $call) {
|
||||
$id = is_string($call['id'] ?? null) && $call['id'] !== ''
|
||||
? $call['id']
|
||||
: md5(json_encode($call));
|
||||
// 使用 index 作为主键(OpenAI 流式 API 的标准做法)
|
||||
$index = is_int($call['index'] ?? null) ? (int) $call['index'] : 0;
|
||||
|
||||
$index = is_int($call['index'] ?? null) ? (int) $call['index'] : count($order);
|
||||
|
||||
if (! isset($buffer[$id])) {
|
||||
$buffer[$id] = [
|
||||
'id' => $id,
|
||||
if (! isset($buffer[$index])) {
|
||||
$buffer[$index] = [
|
||||
'id' => $call['id'] ?? null,
|
||||
'name' => $call['name'] ?? null,
|
||||
'arguments' => '',
|
||||
'index' => $index,
|
||||
];
|
||||
$order[$id] = $index;
|
||||
$order[$index] = count($order);
|
||||
}
|
||||
|
||||
// 更新 id(第一个 chunk 才有)
|
||||
if (isset($call['id']) && is_string($call['id']) && $call['id'] !== '') {
|
||||
$buffer[$index]['id'] = $call['id'];
|
||||
}
|
||||
|
||||
// 更新 name(第一个 chunk 才有)
|
||||
if (isset($call['name']) && is_string($call['name']) && $call['name'] !== '') {
|
||||
$buffer[$id]['name'] = $call['name'];
|
||||
$buffer[$index]['name'] = $call['name'];
|
||||
}
|
||||
|
||||
// 累积 arguments
|
||||
$arguments = $call['arguments'] ?? '';
|
||||
if (is_string($arguments) && $arguments !== '') {
|
||||
$buffer[$id]['arguments'] .= $arguments;
|
||||
$buffer[$index]['arguments'] .= $arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将缓存的 tool.call 增量整理为最终列表(保持 provider 给出的顺序)。
|
||||
* 将缓存的 tool.call 增量整理为最终列表(按 index 排序)。
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $buffer
|
||||
* @param array<string, int> $order
|
||||
* @param array<int, array<string, mixed>> $buffer 以 index 为 key 的缓冲区
|
||||
* @param array<int, int> $order 记录 index 出现顺序
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function finalizeToolCalls(array $buffer, array $order, ?string $doneReason): array
|
||||
@@ -654,12 +664,8 @@ class RunLoop
|
||||
return [];
|
||||
}
|
||||
|
||||
uasort($buffer, function ($a, $b) use ($order) {
|
||||
$orderA = $order[$a['id']] ?? ($a['index'] ?? 0);
|
||||
$orderB = $order[$b['id']] ?? ($b['index'] ?? 0);
|
||||
|
||||
return $orderA <=> $orderB;
|
||||
});
|
||||
// 按 index 排序
|
||||
ksort($buffer);
|
||||
|
||||
return array_values(array_map(function (array $call) use ($doneReason) {
|
||||
return [
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Services\Tool;
|
||||
|
||||
use App\Services\Tool\Tools\BashTool;
|
||||
use App\Services\Tool\Tools\GetTimeTool;
|
||||
use App\Services\Tool\Tools\LsTool;
|
||||
|
||||
/**
|
||||
* Tool 注册表:管理已注册工具与 OpenAI 兼容的声明。
|
||||
@@ -21,6 +23,8 @@ class ToolRegistry
|
||||
{
|
||||
$tools = $tools ?? [
|
||||
new GetTimeTool(),
|
||||
new LsTool(),
|
||||
new BashTool(),
|
||||
];
|
||||
|
||||
$this->tools = [];
|
||||
|
||||
59
app/Services/Tool/Tools/BashTool.php
Normal file
59
app/Services/Tool/Tools/BashTool.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Tool\Tools;
|
||||
|
||||
use App\Services\Tool\Tool;
|
||||
|
||||
class BashTool implements Tool
|
||||
{
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Execute bash commands';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function parameters(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'directory' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The directory to list',
|
||||
'default' => '.',
|
||||
],
|
||||
'file' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The file to read',
|
||||
],
|
||||
'command' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The command to execute',
|
||||
],
|
||||
'user_confirmation_message' => [
|
||||
'type' => 'string',
|
||||
'description' => ' A message describing the purpose of this command, shown to the user for approval before execution ',
|
||||
]
|
||||
],
|
||||
'required' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function execute(array $arguments): array|string
|
||||
{
|
||||
return [
|
||||
'output' => shell_exec($arguments['command']),
|
||||
];
|
||||
}
|
||||
}
|
||||
256
app/Services/Tool/Tools/LsTool.php
Normal file
256
app/Services/Tool/Tools/LsTool.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Tool\Tools;
|
||||
|
||||
use App\Services\Tool\Tool;
|
||||
use FilesystemIterator;
|
||||
use InvalidArgumentException;
|
||||
use SplFileInfo;
|
||||
|
||||
class LsTool implements Tool
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'ls';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '列出指定目录下的文件与目录(支持过滤、排序与详情输出)。';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function parameters(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'directory' => [
|
||||
'type' => 'string',
|
||||
'description' => '要列出的目录路径,默认为当前目录。',
|
||||
'default' => '.',
|
||||
],
|
||||
'include_hidden' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否包含以 . 开头的隐藏文件/目录(默认包含,兼容旧行为)。',
|
||||
'default' => true,
|
||||
],
|
||||
'filter' => [
|
||||
'type' => 'string',
|
||||
'description' => '过滤类型:all/files/directories。',
|
||||
'enum' => ['all', 'files', 'directories'],
|
||||
'default' => 'all',
|
||||
],
|
||||
'match' => [
|
||||
'type' => 'string',
|
||||
'description' => '可选的通配符匹配(fnmatch),例如:*.php。',
|
||||
],
|
||||
'sort' => [
|
||||
'type' => 'string',
|
||||
'description' => '排序:name_asc/name_desc/mtime_asc/mtime_desc。',
|
||||
'enum' => ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'],
|
||||
'default' => 'name_asc',
|
||||
],
|
||||
'details' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否返回条目详情(name/type/size/modified_at)。',
|
||||
'default' => false,
|
||||
],
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'description' => '最多返回条目数量(1-1000),默认 200。',
|
||||
'minimum' => 1,
|
||||
'maximum' => 1000,
|
||||
'default' => 200,
|
||||
],
|
||||
],
|
||||
'required' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function execute(array $arguments): array
|
||||
{
|
||||
$directory = $this->stringOrDefault($arguments, 'directory', '.');
|
||||
|
||||
if (! is_dir($directory) || ! is_readable($directory)) {
|
||||
throw new InvalidArgumentException('目录不存在或不可读:'.$directory);
|
||||
}
|
||||
|
||||
$includeHidden = $this->boolOrDefault($arguments, 'include_hidden', true);
|
||||
$details = $this->boolOrDefault($arguments, 'details', false);
|
||||
$filter = $this->enumOrDefault($arguments, 'filter', ['all', 'files', 'directories'], 'all');
|
||||
$sort = $this->enumOrDefault($arguments, 'sort', ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'], 'name_asc');
|
||||
$match = $this->nullableString($arguments, 'match');
|
||||
$limit = $this->intOrDefault($arguments, 'limit', 200);
|
||||
$limit = max(1, min(1000, $limit));
|
||||
|
||||
$rawEntries = $this->collectEntries($directory, $includeHidden, $filter, $match);
|
||||
$rawEntries = $this->sortEntries($rawEntries, $sort);
|
||||
$rawEntries = array_slice($rawEntries, 0, $limit);
|
||||
|
||||
$entries = $details
|
||||
? array_map(fn (array $entry) => $entry, $rawEntries)
|
||||
: array_map(fn (array $entry) => $entry['name'], $rawEntries);
|
||||
|
||||
return [
|
||||
'directory' => $directory,
|
||||
'entries' => array_values($entries),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
|
||||
*/
|
||||
private function collectEntries(string $directory, bool $includeHidden, string $filter, ?string $match): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
try {
|
||||
$iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
|
||||
} catch (\Throwable $exception) {
|
||||
throw new InvalidArgumentException('无法读取目录:'.$directory, 0, $exception);
|
||||
}
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (! $fileInfo instanceof SplFileInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $fileInfo->getFilename();
|
||||
|
||||
if (! $includeHidden && str_starts_with($name, '.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($filter === 'files' && ! $fileInfo->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($filter === 'directories' && ! $fileInfo->isDir()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($match) && $match !== '' && ! fnmatch($match, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'name' => $name,
|
||||
'type' => $this->entryType($fileInfo),
|
||||
'size' => $fileInfo->isFile() ? $fileInfo->getSize() : null,
|
||||
'modified_at' => $fileInfo->getMTime(),
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, type: string, size: int|null, modified_at: int}> $entries
|
||||
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
|
||||
*/
|
||||
private function sortEntries(array $entries, string $sort): array
|
||||
{
|
||||
usort($entries, function (array $left, array $right) use ($sort) {
|
||||
if ($sort === 'mtime_asc' || $sort === 'mtime_desc') {
|
||||
$compare = $left['modified_at'] <=> $right['modified_at'];
|
||||
} else {
|
||||
$compare = strnatcasecmp($left['name'], $right['name']);
|
||||
}
|
||||
|
||||
if ($compare === 0) {
|
||||
$compare = strnatcasecmp($left['name'], $right['name']);
|
||||
}
|
||||
|
||||
return ($sort === 'name_desc' || $sort === 'mtime_desc') ? -$compare : $compare;
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function entryType(SplFileInfo $fileInfo): string
|
||||
{
|
||||
if ($fileInfo->isLink()) {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
if ($fileInfo->isDir()) {
|
||||
return 'directory';
|
||||
}
|
||||
|
||||
if ($fileInfo->isFile()) {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
private function stringOrDefault(array $arguments, string $key, string $default): string
|
||||
{
|
||||
$value = $arguments[$key] ?? null;
|
||||
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
throw new InvalidArgumentException($key.' 必须是非空字符串');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<int, string> $allowed
|
||||
*/
|
||||
private function enumOrDefault(array $arguments, string $key, array $allowed, string $default): string
|
||||
{
|
||||
$value = $arguments[$key] ?? null;
|
||||
|
||||
if (! is_string($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array($value, $allowed, true) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
private function boolOrDefault(array $arguments, string $key, bool $default): bool
|
||||
{
|
||||
$value = $arguments[$key] ?? null;
|
||||
|
||||
return is_bool($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
private function intOrDefault(array $arguments, string $key, int $default): int
|
||||
{
|
||||
$value = $arguments[$key] ?? null;
|
||||
|
||||
return is_int($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
private function nullableString(array $arguments, string $key): ?string
|
||||
{
|
||||
$value = $arguments[$key] ?? null;
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
189
tests/Unit/LsToolTest.php
Normal file
189
tests/Unit/LsToolTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
161
tests/Unit/RunLoopToolCallTest.php
Normal file
161
tests/Unit/RunLoopToolCallTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user