- 添加 `FileReadTool`,支持文件内容读取与安全验证 - 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理 - 修改工具选项逻辑,支持禁用工具时的动态调整 - 增加消息序列化逻辑,优化 Redis 序列管理与数据同步 - 扩展测试覆盖,验证序列化与工具调用场景 - 增强 Docker Compose 脚本,支持应用重置与日志清理 - 调整工具调用超时设置,提升运行时用户体验
373 lines
13 KiB
PHP
373 lines
13 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
class ChatSessionTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function authHeader(User $user): array
|
|
{
|
|
return ['Authorization' => 'Bearer '.JWTAuth::fromUser($user)];
|
|
}
|
|
|
|
public function test_append_messages_sequential_seq_and_last_seq(): void
|
|
{
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Test Session');
|
|
|
|
$messages = collect(range(1, 20))->map(function ($i) use ($service, $session) {
|
|
return $service->appendMessage([
|
|
'session_id' => $session->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'msg '.$i,
|
|
]);
|
|
});
|
|
|
|
$seqs = $messages->pluck('seq')->all();
|
|
$this->assertEquals(range(1, 20), $seqs);
|
|
|
|
$session->refresh();
|
|
$this->assertEquals(20, $session->last_seq);
|
|
}
|
|
|
|
public function test_dedupe_returns_existing_message(): void
|
|
{
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Test Session');
|
|
|
|
$first = $service->appendMessage([
|
|
'session_id' => $session->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello',
|
|
'dedupe_key' => 'k1',
|
|
]);
|
|
|
|
$second = $service->appendMessage([
|
|
'session_id' => $session->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello again',
|
|
'dedupe_key' => 'k1',
|
|
]);
|
|
|
|
$this->assertEquals($first->message_id, $second->message_id);
|
|
$this->assertEquals($first->seq, $second->seq);
|
|
$this->assertCount(1, $session->messages()->get());
|
|
}
|
|
|
|
public function test_closed_session_blocks_append_except_whitelisted(): void
|
|
{
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Test Session');
|
|
$session->update(['status' => ChatSessionStatus::CLOSED]);
|
|
|
|
$this->expectExceptionMessage('Session is closed');
|
|
$service->appendMessage([
|
|
'session_id' => $session->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'blocked',
|
|
]);
|
|
}
|
|
|
|
public function test_http_append_and_list_messages(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
|
|
$createSession = $this->withHeaders($headers)->postJson('/api/sessions', [
|
|
'session_name' => 'API Session',
|
|
])->assertCreated();
|
|
|
|
$sessionId = $createSession->json('data.session_id');
|
|
|
|
$append = $this->withHeaders($headers)->postJson("/api/sessions/{$sessionId}/messages", [
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello api',
|
|
])->assertCreated();
|
|
|
|
$this->assertEquals(1, $append->json('data.seq'));
|
|
|
|
$list = $this->withHeaders($headers)->getJson("/api/sessions/{$sessionId}/messages?after_seq=0&limit=10")
|
|
->assertOk();
|
|
|
|
$this->assertEquals(1, $list->json('data.0.seq'));
|
|
$this->assertEquals('hello api', $list->json('data.0.content'));
|
|
}
|
|
|
|
public function test_session_list_sorted_and_last_message_preview(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
|
|
Carbon::setTestNow('2025-01-01 00:00:00');
|
|
$s1 = $service->createSession('First');
|
|
$service->appendMessage([
|
|
'session_id' => $s1->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello first',
|
|
]);
|
|
|
|
Carbon::setTestNow('2025-01-01 00:00:10');
|
|
$s2 = $service->createSession('Second');
|
|
$service->appendMessage([
|
|
'session_id' => $s2->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello second',
|
|
]);
|
|
|
|
Carbon::setTestNow();
|
|
|
|
$resp = $this->withHeaders($headers)->getJson('/api/sessions?per_page=10')
|
|
->assertOk();
|
|
|
|
$this->assertEquals($s2->session_id, $resp->json('data.0.session_id'));
|
|
$this->assertEquals('hello second', $resp->json('data.0.last_message_preview'));
|
|
$this->assertEquals('hello first', $resp->json('data.1.last_message_preview'));
|
|
$this->assertNotNull($resp->json('data.0.last_message_at'));
|
|
}
|
|
|
|
public function test_session_list_filters_status_and_query(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
|
|
$open = $service->createSession('Alpha');
|
|
$locked = $service->createSession('Beta');
|
|
$locked->update(['status' => ChatSessionStatus::LOCKED]);
|
|
$closed = $service->createSession('Gamma');
|
|
$closed->update(['status' => ChatSessionStatus::CLOSED]);
|
|
|
|
$respStatus = $this->withHeaders($headers)->getJson('/api/sessions?status=LOCKED')
|
|
->assertOk();
|
|
$this->assertCount(1, $respStatus->json('data'));
|
|
$this->assertEquals($locked->session_id, $respStatus->json('data.0.session_id'));
|
|
|
|
$respQuery = $this->withHeaders($headers)->getJson('/api/sessions?q=Alpha')
|
|
->assertOk();
|
|
$this->assertCount(1, $respQuery->json('data'));
|
|
$this->assertEquals($open->session_id, $respQuery->json('data.0.session_id'));
|
|
}
|
|
|
|
public function test_patch_updates_session_name(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Old Name');
|
|
|
|
$resp = $this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
|
|
'session_name' => 'New Name',
|
|
])->assertOk();
|
|
|
|
$this->assertEquals('New Name', $resp->json('data.session_name'));
|
|
}
|
|
|
|
public function test_patch_updates_status_transitions(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Status Session');
|
|
|
|
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
|
|
'status' => ChatSessionStatus::LOCKED,
|
|
])->assertOk()->assertJsonFragment(['status' => ChatSessionStatus::LOCKED]);
|
|
|
|
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
|
|
'status' => ChatSessionStatus::OPEN,
|
|
])->assertOk()->assertJsonFragment(['status' => ChatSessionStatus::OPEN]);
|
|
}
|
|
|
|
public function test_closed_session_cannot_reopen(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Closed Session');
|
|
$session->update(['status' => ChatSessionStatus::CLOSED]);
|
|
|
|
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [
|
|
'status' => ChatSessionStatus::OPEN,
|
|
])->assertStatus(403);
|
|
}
|
|
|
|
public function test_empty_patch_rejected(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Patch Empty');
|
|
|
|
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [])
|
|
->assertStatus(422);
|
|
}
|
|
|
|
public function test_archive_is_idempotent_and_blocks_user_prompt(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('Archive');
|
|
|
|
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
|
|
->assertOk()
|
|
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
|
|
|
|
// repeat archive
|
|
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
|
|
->assertOk()
|
|
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
|
|
|
|
// append should be blocked
|
|
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/messages", [
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'blocked',
|
|
])->assertStatus(403);
|
|
}
|
|
|
|
public function test_get_message_respects_session_scope(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$s1 = $service->createSession('S1');
|
|
$s2 = $service->createSession('S2');
|
|
|
|
$msg1 = $service->appendMessage([
|
|
'session_id' => $s1->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello',
|
|
]);
|
|
|
|
$this->withHeaders($headers)->getJson("/api/sessions/{$s1->session_id}/messages/{$msg1->message_id}")
|
|
->assertOk()
|
|
->assertJsonFragment(['message_id' => $msg1->message_id]);
|
|
|
|
// wrong session should 404
|
|
$this->withHeaders($headers)->getJson("/api/sessions/{$s2->session_id}/messages/{$msg1->message_id}")
|
|
->assertNotFound();
|
|
}
|
|
|
|
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);
|
|
$session = $service->createSession('Redis Pub');
|
|
|
|
$service->appendMessage([
|
|
'session_id' => $session->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello',
|
|
]);
|
|
}
|
|
|
|
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();
|
|
$headers = $this->authHeader($user);
|
|
$service = app(ChatService::class);
|
|
$session = $service->createSession('SSE Session');
|
|
$service->appendMessage([
|
|
'session_id' => $session->session_id,
|
|
'role' => 'USER',
|
|
'type' => 'user.prompt',
|
|
'content' => 'hello sse',
|
|
]);
|
|
|
|
$response = $this->withHeaders($headers)->get("/api/sessions/{$session->session_id}/sse?after_seq=0");
|
|
|
|
$response->assertOk();
|
|
$content = $response->baseResponse instanceof \Symfony\Component\HttpFoundation\StreamedResponse
|
|
? $response->streamedContent()
|
|
: $response->getContent();
|
|
$this->assertStringContainsString('id: 1', $content);
|
|
$this->assertStringContainsString('event: message', $content);
|
|
$this->assertStringContainsString('hello sse', $content);
|
|
}
|
|
}
|