From e956df9daa99bf3c01afd0c78cf798f0e9496c56 Mon Sep 17 00:00:00 2001 From: ROOG Date: Wed, 24 Dec 2025 00:55:54 +0800 Subject: [PATCH] =?UTF-8?q?main:=20=E5=A2=9E=E5=BC=BA=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=B8=8E=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 `FileReadTool`,支持文件内容读取与安全验证 - 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理 - 修改工具选项逻辑,支持禁用工具时的动态调整 - 增加消息序列化逻辑,优化 Redis 序列管理与数据同步 - 扩展测试覆盖,验证序列化与工具调用场景 - 增强 Docker Compose 脚本,支持应用重置与日志清理 - 调整工具调用超时设置,提升运行时用户体验 --- .env.example | 2 +- AGENTS.md | 0 CLAUDE.md | 0 README.md | 0 .../OpenAi/ChatCompletionsRequestBuilder.php | 27 ++- app/Services/ChatService.php | 86 ++++--- app/Services/MessageSequence.php | 59 +++++ app/Services/OutputSink.php | 12 +- app/Services/Tool/ToolRegistry.php | 2 + app/Services/Tool/Tools/FileReadTool.php | 217 ++++++++++++++++++ backup.yaml | 0 boost.json | 0 composer.json | 0 composer.lock | 0 docker-compose.yml | 2 + package.json | 0 phpunit.xml | 0 script/clear-log.sh | 53 +++++ script/reset-app.sh | 77 +++++++ script/restart-docker-compose.sh | 67 ++++++ script/run-tests-seed.sh | 67 ++++++ tests/Feature/ChatSessionTest.php | 63 +++++ tests/Unit/OpenAiAdapterTest.php | 45 ++++ vite.config.js | 0 24 files changed, 741 insertions(+), 38 deletions(-) mode change 100644 => 100755 AGENTS.md mode change 100644 => 100755 CLAUDE.md mode change 100644 => 100755 README.md create mode 100644 app/Services/MessageSequence.php create mode 100644 app/Services/Tool/Tools/FileReadTool.php mode change 100644 => 100755 backup.yaml mode change 100644 => 100755 boost.json mode change 100644 => 100755 composer.json mode change 100644 => 100755 composer.lock mode change 100644 => 100755 docker-compose.yml mode change 100644 => 100755 package.json mode change 100644 => 100755 phpunit.xml create mode 100644 script/clear-log.sh create mode 100755 script/reset-app.sh create mode 100644 script/restart-docker-compose.sh create mode 100644 script/run-tests-seed.sh mode change 100644 => 100755 vite.config.js diff --git a/.env.example b/.env.example index 93a5ed2..4f45158 100644 --- a/.env.example +++ b/.env.example @@ -92,7 +92,7 @@ AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒) # Tool 子 Run 调度与超时 AGENT_TOOL_MAX_CALLS_PER_RUN=99 # 单个父 Run 允许的工具调用次数 -AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒) +AGENT_TOOL_WAIT_TIMEOUT_MS=30000 # 等待 tool.result 的超时时间(毫秒) AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒) AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT) AGENT_TOOL_RESULT_MAX_BYTES=4096 # 工具结果最大保存字节数(截断后仍会写入) diff --git a/AGENTS.md b/AGENTS.md old mode 100644 new mode 100755 diff --git a/CLAUDE.md b/CLAUDE.md old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php b/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php index b33b48b..ced67fa 100644 --- a/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php +++ b/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php @@ -68,13 +68,22 @@ class ChatCompletionsRequestBuilder } $toolsSpec = $this->toolRegistry->openAiToolsSpec(); + $hasToolMessages = $this->hasToolMessages($context); // 支持 disable_tools 选项,用于在达到工具调用上限后禁用工具 $disableTools = $options['disable_tools'] ?? false; - if (! empty($toolsSpec) && ! $disableTools) { - $payload['tools'] = $toolsSpec; - $payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto'; + if (! empty($toolsSpec)) { + if ($disableTools) { + $payload['tool_choice'] = 'none'; + if ($hasToolMessages) { + // 历史包含工具消息时仍需携带 tools 定义以满足接口校验 + $payload['tools'] = $toolsSpec; + } + } else { + $payload['tools'] = $toolsSpec; + $payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto'; + } } return $payload; @@ -219,4 +228,16 @@ class ChatCompletionsRequestBuilder 'content' => $resultContent, ]; } + + private function hasToolMessages(AgentContext $context): bool + { + foreach ($context->messages as $message) { + $type = (string) ($message['type'] ?? ''); + if ($type === 'tool.call' || $type === 'tool.result') { + return true; + } + } + + return false; + } } diff --git a/app/Services/ChatService.php b/app/Services/ChatService.php index 227a3ec..13261f5 100644 --- a/app/Services/ChatService.php +++ b/app/Services/ChatService.php @@ -16,6 +16,9 @@ use Illuminate\Support\Str; class ChatService { + public function __construct(private readonly MessageSequence $messageSequence) + { + } /** * 创建一个新聊天会话。 @@ -88,41 +91,55 @@ class ChatService } } - $newSeq = $session->last_seq + 1; + $attempts = 0; + while (true) { + $attempts++; + $newSeq = $this->messageSequence->nextForSession($session); - $message = new Message([ - 'message_id' => (string) Str::uuid(), - 'session_id' => $session->session_id, - 'role' => $dto['role'], - 'type' => $dto['type'], - 'content' => $dto['content'] ?? null, - 'payload' => $dto['payload'] ?? null, - 'reply_to' => $dto['reply_to'] ?? null, - 'dedupe_key' => $dedupeKey, - 'seq' => $newSeq, - 'created_at' => now(), - ]); + $message = new Message([ + 'message_id' => (string) Str::uuid(), + 'session_id' => $session->session_id, + 'role' => $dto['role'], + 'type' => $dto['type'], + 'content' => $dto['content'] ?? null, + 'payload' => $dto['payload'] ?? null, + 'reply_to' => $dto['reply_to'] ?? null, + 'dedupe_key' => $dedupeKey, + 'seq' => $newSeq, + 'created_at' => now(), + ]); - try { - if ($save) { - $message->save(); - } - $isNew = true; - } catch (QueryException $e) { - if ($this->isUniqueConstraint($e) && $dedupeKey) { - $existing = Message::query() - ->where('session_id', $session->session_id) - ->where('dedupe_key', $dedupeKey) - ->first(); - - if ($existing) { - $messageRef = $existing; - $wasDeduped = true; - return; + try { + if ($save) { + $message->save(); } - } + $isNew = true; + break; + } catch (QueryException $e) { + if ($this->isUniqueConstraint($e) && $dedupeKey) { + $existing = Message::query() + ->where('session_id', $session->session_id) + ->where('dedupe_key', $dedupeKey) + ->first(); - throw $e; + if ($existing) { + $messageRef = $existing; + $wasDeduped = true; + return; + } + } + + if ($this->isUniqueConstraint($e) && $this->isSeqUniqueConstraint($e) && $attempts < 3) { + $maxPersistedSeq = (int) (Message::query() + ->where('session_id', $session->session_id) + ->max('seq') ?? 0); + + $this->messageSequence->syncToAtLeast($session->session_id, max($session->last_seq, $maxPersistedSeq)); + continue; + } + + throw $e; + } } $session->update([ @@ -327,4 +344,11 @@ class ChatService return $sqlState === '23505'; } + + private function isSeqUniqueConstraint(QueryException $e): bool + { + $details = $e->errorInfo[2] ?? $e->getMessage(); + + return is_string($details) && str_contains($details, 'messages_session_id_seq_unique'); + } } diff --git a/app/Services/MessageSequence.php b/app/Services/MessageSequence.php new file mode 100644 index 0000000..7f5ec2b --- /dev/null +++ b/app/Services/MessageSequence.php @@ -0,0 +1,59 @@ +redisKey($session->session_id); + + try { + $current = Redis::get($key); + + if ($current === null) { + $seed = $this->seedFromDatabase($session); + Redis::setnx($key, $seed); + } elseif ((int) $current < $session->last_seq) { + Redis::set($key, (string) $session->last_seq); + } + + return (int) Redis::incr($key); + } catch (\Throwable) { + return $session->last_seq + 1; + } + } + + public function syncToAtLeast(string $sessionId, int $seq): void + { + $key = $this->redisKey($sessionId); + + try { + $current = Redis::get($key); + if ($current === null || (int) $current < $seq) { + Redis::set($key, (string) $seq); + } + } catch (\Throwable) { + return; + } + } + + private function seedFromDatabase(ChatSession $session): int + { + $maxPersistedSeq = (int) (Message::query() + ->where('session_id', $session->session_id) + ->max('seq') ?? 0); + + return max($session->last_seq, $maxPersistedSeq); + } + + private function redisKey(string $sessionId): string + { + return "chat_session:{$sessionId}:seq"; + } +} + diff --git a/app/Services/OutputSink.php b/app/Services/OutputSink.php index a23a064..2b42b3e 100644 --- a/app/Services/OutputSink.php +++ b/app/Services/OutputSink.php @@ -3,12 +3,16 @@ namespace App\Services; use App\Models\Message; +use App\Services\MessageSequence; use App\Services\Tool\ToolCall; use App\Services\Tool\ToolResult; class OutputSink { - public function __construct(private readonly ChatService $chatService) + public function __construct( + private readonly ChatService $chatService, + private readonly MessageSequence $messageSequence, + ) { } @@ -39,10 +43,12 @@ class OutputSink */ public function appendAgentDelta(string $sessionId, string $runId, string $content, int $deltaIndex, array $meta = []): void { + $session = $this->chatService->getSession($sessionId); + // 1. 创建临时 Message 对象(不保存到数据库) $message = new Message([ 'message_id' => (string) \Illuminate\Support\Str::uuid(), - 'session_id' => $sessionId, + 'session_id' => $session->session_id, 'role' => Message::ROLE_AGENT, 'type' => 'message.delta', 'content' => $content, @@ -51,7 +57,7 @@ class OutputSink 'delta_index' => $deltaIndex, ]), 'dedupe_key' => "run:{$runId}:agent:delta:{$deltaIndex}", - 'seq' => 0, // delta 消息不需要真实的 seq + 'seq' => $this->messageSequence->nextForSession($session), 'created_at' => now(), ]); diff --git a/app/Services/Tool/ToolRegistry.php b/app/Services/Tool/ToolRegistry.php index 0710fda..42895c2 100644 --- a/app/Services/Tool/ToolRegistry.php +++ b/app/Services/Tool/ToolRegistry.php @@ -5,6 +5,7 @@ namespace App\Services\Tool; use App\Services\Tool\Tools\BashTool; use App\Services\Tool\Tools\GetTimeTool; use App\Services\Tool\Tools\LsTool; +use App\Services\Tool\Tools\FileReadTool; /** * Tool 注册表:管理已注册工具与 OpenAI 兼容的声明。 @@ -25,6 +26,7 @@ class ToolRegistry new GetTimeTool(), new LsTool(), new BashTool(), + new FileReadTool(), ]; $this->tools = []; diff --git a/app/Services/Tool/Tools/FileReadTool.php b/app/Services/Tool/Tools/FileReadTool.php new file mode 100644 index 0000000..4ad4223 --- /dev/null +++ b/app/Services/Tool/Tools/FileReadTool.php @@ -0,0 +1,217 @@ + + */ + public function parameters(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'path' => [ + 'type' => 'string', + 'description' => '要读取的文件路径(相对或绝对路径)。', + ], + 'start_line' => [ + 'type' => 'integer', + 'description' => '起始行号(从1开始),默认从第一行开始。', + 'minimum' => 1, + 'default' => 1, + ], + 'end_line' => [ + 'type' => 'integer', + 'description' => '结束行号(包含),默认读取到文件末尾。', + 'minimum' => 1, + ], + 'max_size' => [ + 'type' => 'integer', + 'description' => '最大读取字节数(1-10MB),默认1MB,防止读取过大文件。', + 'minimum' => 1, + 'maximum' => 10485760, + 'default' => 1048576, + ], + 'encoding' => [ + 'type' => 'string', + 'description' => '文件编码,默认UTF-8。', + 'enum' => ['UTF-8', 'GBK', 'GB2312', 'ISO-8859-1'], + 'default' => 'UTF-8', + ], + ], + 'required' => ['path'], + ]; + } + + /** + * @param array $arguments + * @return array + */ + public function execute(array $arguments): array + { + $path = $arguments['path'] ?? ''; + + // 验证路径 + if (empty($path)) { + throw new InvalidArgumentException('文件路径不能为空。'); + } + + // 安全检查:防止路径遍历攻击 + $realPath = realpath($path); + if ($realPath === false) { + throw new InvalidArgumentException("文件不存在:{$path}"); + } + + if (!is_file($realPath)) { + throw new InvalidArgumentException("路径不是文件:{$path}"); + } + + if (!is_readable($realPath)) { + throw new InvalidArgumentException("文件不可读:{$path}"); + } + + // 获取参数 + $startLine = max(1, (int)($arguments['start_line'] ?? 1)); + $endLine = isset($arguments['end_line']) ? max(1, (int)$arguments['end_line']) : null; + $maxSize = min(10485760, max(1, (int)($arguments['max_size'] ?? 1048576))); + $encoding = $arguments['encoding'] ?? 'UTF-8'; + + // 检查文件大小 + $fileSize = filesize($realPath); + if ($fileSize === false) { + throw new InvalidArgumentException("无法获取文件大小:{$path}"); + } + + return $this->readFileContent($realPath, $startLine, $endLine, $maxSize, $encoding, $fileSize); + } + + /** + * 读取文件内容 + * + * @param string $path + * @param int $startLine + * @param int|null $endLine + * @param int $maxSize + * @param string $encoding + * @param int $fileSize + * @return array + */ + private function readFileContent( + string $path, + int $startLine, + ?int $endLine, + int $maxSize, + string $encoding, + int $fileSize + ): array { + $result = [ + 'path' => $path, + 'size' => $fileSize, + 'encoding' => $encoding, + ]; + + // 如果文件为空 + if ($fileSize === 0) { + $result['content'] = ''; + $result['lines_read'] = 0; + $result['truncated'] = false; + return $result; + } + + // 读取文件 + $handle = fopen($path, 'r'); + if ($handle === false) { + throw new InvalidArgumentException("无法打开文件:{$path}"); + } + + try { + return $this->readLines($handle, $startLine, $endLine, $maxSize, $encoding, $result); + } finally { + fclose($handle); + } + } + + /** + * 按行读取文件 + * + * @param resource $handle + * @param int $startLine + * @param int|null $endLine + * @param int $maxSize + * @param string $encoding + * @param array $result + * @return array + */ + private function readLines( + $handle, + int $startLine, + ?int $endLine, + int $maxSize, + string $encoding, + array $result + ): array { + $lines = []; + $currentLine = 0; + $bytesRead = 0; + $truncated = false; + + while (($line = fgets($handle)) !== false) { + $currentLine++; + + // 跳过起始行之前的内容 + if ($currentLine < $startLine) { + continue; + } + + // 检查是否超过结束行 + if ($endLine !== null && $currentLine > $endLine) { + break; + } + + // 检查大小限制 + $lineLength = strlen($line); + if ($bytesRead + $lineLength > $maxSize) { + $truncated = true; + break; + } + + $lines[] = $line; + $bytesRead += $lineLength; + } + + $content = implode('', $lines); + + // 编码转换 + if ($encoding !== 'UTF-8' && function_exists('mb_convert_encoding')) { + $content = mb_convert_encoding($content, 'UTF-8', $encoding); + } + + $result['content'] = $content; + $result['lines_read'] = count($lines); + $result['start_line'] = $startLine; + $result['end_line'] = $endLine ?? $currentLine; + $result['truncated'] = $truncated; + $result['bytes_read'] = $bytesRead; + + if ($truncated) { + $result['warning'] = "内容已截断,已读取 {$bytesRead} 字节(限制:{$maxSize} 字节)"; + } + + return $result; + } +} diff --git a/backup.yaml b/backup.yaml old mode 100644 new mode 100755 diff --git a/boost.json b/boost.json old mode 100644 new mode 100755 diff --git a/composer.json b/composer.json old mode 100644 new mode 100755 diff --git a/composer.lock b/composer.lock old mode 100644 new mode 100755 diff --git a/docker-compose.yml b/docker-compose.yml old mode 100644 new mode 100755 index 7e9472a..382a342 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: entrypoint: ["/app/docker/app/entrypoint.sh"] volumes: - ./:/app + #- ../ars-front:/app-frontend environment: APP_ENV: local APP_DEBUG: "true" @@ -55,6 +56,7 @@ services: QUEUE_CONNECTION: redis volumes: - ./:/app + #- ../ars-front:/app-frontend depends_on: - pgsql - redis diff --git a/package.json b/package.json old mode 100644 new mode 100755 diff --git a/phpunit.xml b/phpunit.xml old mode 100644 new mode 100755 diff --git a/script/clear-log.sh b/script/clear-log.sh new file mode 100644 index 0000000..0cc4317 --- /dev/null +++ b/script/clear-log.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# 定义颜色 +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 打印带颜色的信息 +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +# 打印分割线 +print_separator() { + echo -e "${BLUE}────────────────────────────────────${NC}" +} + +# 主流程 +print_separator +print_info "清理日志" +print_separator +echo "" + +# Step 1: 运行测试 +print_info "Step 1/1 清理日志" +echo "" +if docker compose exec -T app rm -rf /app/storage/logs/laravel-*.log ; then + echo "" + print_success "删除日志成功" +else + echo "" + print_error "删除日志失败" + exit 1 +fi +echo "" +# 完成 +print_separator +print_success "🎉 测试和数据库填充全部完成!" +print_separator diff --git a/script/reset-app.sh b/script/reset-app.sh new file mode 100755 index 0000000..19b6a2b --- /dev/null +++ b/script/reset-app.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +# 获取脚本所在目录的绝对路径 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 定义颜色 +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 打印带颜色的信息 +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +# 打印分割线 +print_separator() { + echo -e "${BLUE}════════════════════════════════════${NC}" +} + +# 主流程 +print_separator +print_info "开始重置应用" +print_separator +echo "" + +# Step 1: 重启 Docker Compose +print_info "Step 1/3: 重启 Docker Compose" +echo "" +if bash "${SCRIPT_DIR}/restart-docker-compose.sh"; then + print_success "Docker Compose 重启成功" +else + print_error "Docker Compose 重启失败" + exit 1 +fi +echo "" + +# Step 2: 运行测试和数据填充 +print_info "Step 2/3: 运行测试和数据填充" +echo "" +if bash "${SCRIPT_DIR}/run-tests-seed.sh"; then + print_success "测试和数据填充成功" +else + print_error "测试和数据填充失败" + exit 1 +fi +echo "" + +# Step 3: 清理日志 +print_info "Step 3/3: 清理日志" +echo "" +if bash "${SCRIPT_DIR}/clear-log.sh"; then + print_success "日志清理成功" +else + print_error "日志清理失败" + exit 1 +fi +echo "" + +# 完成 +print_separator +print_success "🎉 应用重置完成!" +print_separator diff --git a/script/restart-docker-compose.sh b/script/restart-docker-compose.sh new file mode 100644 index 0000000..21b4a60 --- /dev/null +++ b/script/restart-docker-compose.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# 定义颜色 +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 打印带颜色的信息 +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +# 打印分割线 +print_separator() { + echo -e "${BLUE}────────────────────────────────────${NC}" +} + +# 主流程 +print_separator +print_info "Docker Compose 重启脚本" +print_separator +echo "" + +# Step 1: 停止服务 +print_info "Step 1/3: 停止 Docker Compose 服务..." +if docker compose down 2>&1; then + print_success "服务已成功停止" +else + print_error "停止服务失败" + exit 1 +fi +echo "" + +# Step 2: 启动服务 +print_info "Step 2/3: 启动 Docker Compose 服务(后台模式)..." +if docker compose up -d 2>&1; then + print_success "服务已成功启动" +else + print_error "启动服务失败" + exit 1 +fi +echo "" + +# Step 3: 显示状态 +print_info "Step 3/3: 检查服务状态..." +echo "" +docker compose ps +echo "" + +# 完成 +print_separator +print_success "🎉 Docker Compose 服务重启完成!" +print_separator diff --git a/script/run-tests-seed.sh b/script/run-tests-seed.sh new file mode 100644 index 0000000..254de08 --- /dev/null +++ b/script/run-tests-seed.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# 定义颜色 +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 打印带颜色的信息 +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +# 打印分割线 +print_separator() { + echo -e "${BLUE}────────────────────────────────────${NC}" +} + +# 主流程 +print_separator +print_info "Docker Compose 测试与数据库填充脚本" +print_separator +echo "" + +# Step 1: 运行测试 +print_info "Step 1/2: 运行 PHPUnit 测试..." +echo "" +if docker compose exec -T app php artisan test; then + echo "" + print_success "测试通过" +else + echo "" + print_error "测试失败" + exit 1 +fi +echo "" + +# Step 2: 数据库填充 +print_info "Step 2/2: 执行数据库填充..." +echo "" +if docker compose exec -T app php artisan db:seed; then + echo "" + print_success "数据库填充完成" +else + echo "" + print_error "数据库填充失败" + exit 1 +fi +echo "" + +# 完成 +print_separator +print_success "🎉 测试和数据库填充全部完成!" +print_separator diff --git a/tests/Feature/ChatSessionTest.php b/tests/Feature/ChatSessionTest.php index 6c4bb5f..c90cf9a 100644 --- a/tests/Feature/ChatSessionTest.php +++ b/tests/Feature/ChatSessionTest.php @@ -3,11 +3,13 @@ namespace Tests\Feature; use App\Enums\ChatSessionStatus; +use App\Models\Message; use App\Models\User; use App\Services\ChatService; use Illuminate\Support\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Redis; +use Mockery; use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; use Tests\TestCase; @@ -270,6 +272,9 @@ class ChatSessionTest extends TestCase public function test_publish_to_redis_on_append(): void { + Redis::shouldReceive('get')->andReturn(null); + Redis::shouldReceive('setnx')->andReturn(1); + Redis::shouldReceive('incr')->andReturn(1); Redis::shouldReceive('publish')->once()->andReturn(1); $service = app(ChatService::class); @@ -283,6 +288,64 @@ class ChatSessionTest extends TestCase ]); } + public function test_message_seq_seeds_from_db_when_redis_key_missing(): void + { + $service = app(ChatService::class); + $session = $service->createSession('Seed Test'); + + Message::query()->create([ + 'message_id' => (string) \Illuminate\Support\Str::uuid(), + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'existing', + 'payload' => null, + 'seq' => 10, + 'reply_to' => null, + 'dedupe_key' => null, + 'created_at' => now(), + ]); + + Redis::shouldReceive('get')->andReturn(null); + Redis::shouldReceive('setnx')->once()->andReturn(1); + Redis::shouldReceive('incr')->once()->andReturn(11); + Redis::shouldReceive('publish')->zeroOrMoreTimes()->andReturn(1); + + $message = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'new', + ]); + + $this->assertEquals(11, $message->seq); + } + + public function test_agent_delta_uses_redis_seq_and_publishes_with_seq(): void + { + $service = app(ChatService::class); + $session = $service->createSession('Delta Seq'); + + Redis::shouldReceive('get')->andReturn(null); + Redis::shouldReceive('setnx')->once()->with("chat_session:{$session->session_id}:seq", 0)->andReturn(1); + Redis::shouldReceive('incr')->once()->with("chat_session:{$session->session_id}:seq")->andReturn(1); + Redis::shouldReceive('publish')->once()->with( + "session:{$session->session_id}:messages", + Mockery::on(function ($payload) { + $decoded = json_decode($payload, true); + return is_array($decoded) && ($decoded['seq'] ?? null) === 1; + }) + )->andReturn(1); + + app(\App\Services\OutputSink::class)->appendAgentDelta( + $session->session_id, + 'run-1', + 'partial', + 1, + [] + ); + } + public function test_sse_backlog_contains_messages(): void { $user = User::factory()->create(); diff --git a/tests/Unit/OpenAiAdapterTest.php b/tests/Unit/OpenAiAdapterTest.php index 7bfddc4..57f7df6 100644 --- a/tests/Unit/OpenAiAdapterTest.php +++ b/tests/Unit/OpenAiAdapterTest.php @@ -106,6 +106,51 @@ class OpenAiAdapterTest extends TestCase $this->assertSame('call_1', $toolResultMessage['tool_call_id']); } + public function test_disable_tools_still_includes_definitions_when_history_has_tools(): void + { + $context = new AgentContext('run-1', 'session-1', 'system prompt', [ + [ + 'message_id' => 'm1', + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'call tool', + 'payload' => [], + 'seq' => 1, + ], + [ + 'message_id' => 'm2', + 'role' => Message::ROLE_AGENT, + 'type' => 'tool.call', + 'content' => null, + 'payload' => [ + 'tool_call_id' => 'call_1', + 'name' => 'get_time', + 'arguments' => ['format' => 'c'], + ], + 'seq' => 2, + ], + [ + 'message_id' => 'm3', + 'role' => Message::ROLE_TOOL, + 'type' => 'tool.result', + 'content' => '2024-01-01', + 'payload' => [ + 'tool_call_id' => 'call_1', + 'name' => 'get_time', + 'output' => '2024-01-01', + ], + 'seq' => 3, + ], + ]); + + $payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context, [ + 'disable_tools' => true, + ]); + + $this->assertSame('none', $payload['tool_choice']); + $this->assertNotEmpty($payload['tools']); + } + public function test_event_normalizer_maps_delta_and_done(): void { $normalizer = new OpenAiEventNormalizer(); diff --git a/vite.config.js b/vite.config.js old mode 100644 new mode 100755