(string) Str::uuid(), 'session_name' => $name, 'status' => ChatSessionStatus::OPEN, 'last_seq' => 0, ]); } public function getSession(string $sessionId): ChatSession { return ChatSession::query()->whereKey($sessionId)->firstOrFail(); } /** * @param array $dto */ public function appendMessage(array $dto): Message { return DB::transaction(function () use ($dto) { /** @var ChatSession $session */ $session = ChatSession::query() ->whereKey($dto['session_id']) ->lockForUpdate() ->firstOrFail(); $this->ensureCanAppend($session, $dto['role'], $dto['type']); $dedupeKey = $dto['dedupe_key'] ?? null; if ($dedupeKey) { $existing = Message::query() ->where('session_id', $session->session_id) ->where('dedupe_key', $dedupeKey) ->first(); if ($existing) { return $existing; } } $newSeq = $session->last_seq + 1; $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 { $message->save(); } catch (QueryException $e) { if ($this->isUniqueConstraint($e) && $dedupeKey) { $existing = Message::query() ->where('session_id', $session->session_id) ->where('dedupe_key', $dedupeKey) ->first(); if ($existing) { return $existing; } } throw $e; } $session->update([ 'last_seq' => $newSeq, 'updated_at' => now(), ]); return $message; }); } public function listMessagesBySeq(string $sessionId, int $afterSeq, int $limit = 50): Collection { $this->getSession($sessionId); // ensure exists return Message::query() ->where('session_id', $sessionId) ->where('seq', '>', $afterSeq) ->orderBy('seq') ->limit($limit) ->get(); } private function ensureCanAppend(ChatSession $session, string $role, string $type): void { if ($session->status === ChatSessionStatus::CLOSED) { $allowed = $role === Message::ROLE_SYSTEM && in_array($type, ['run.status', 'error'], true); if (! $allowed) { throw new ChatSessionStatusException('Session is closed'); } } if ($session->status === ChatSessionStatus::LOCKED) { if ($role === Message::ROLE_USER && $type === 'user.prompt') { throw new ChatSessionStatusException('Session is locked'); } } } private function isUniqueConstraint(QueryException $e): bool { $sqlState = $e->getCode() ?: ($e->errorInfo[0] ?? null); return $sqlState === '23505'; } }