Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
"permissions": {
"allow": [
"Bash(php artisan make:model:*)",
"Bash(gh issue create:*)"
"Bash(gh issue create:*)",
"Bash(find:*)",
"Bash(git checkout:*)",
"Bash(php artisan make:policy:*)",
"Bash(/usr/local/bin/php:*)",
"Bash(/opt/homebrew/bin/php:*)",
"Bash(git add:*)"
]
},
"enabledMcpjsonServers": [
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- develop
- master

env:
MIN_COVERAGE: 70

jobs:
ci:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -46,5 +49,20 @@ jobs:
- name: Build Assets
run: npm run build

- name: Tests
run: php artisan test
- name: Run Tests with Coverage
run: php artisan test --coverage --min=${{ env.MIN_COVERAGE }}

- name: Generate Coverage Report
if: always()
run: |
php artisan test --coverage-clover=coverage.xml || true
Comment on lines +52 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove || true to avoid hiding test failures.

Line 58 uses || true which forces the command to exit successfully even if tests fail or coverage is below the threshold. This undermines the coverage enforcement on line 53 and could hide legitimate test failures in CI.

The if: always() condition on line 56 already ensures the coverage report is generated regardless of previous step outcomes. The || true is unnecessary and harmful.

Apply this diff:

 - name: Generate Coverage Report
   if: always()
   run: |
-    php artisan test --coverage-clover=coverage.xml || true
+    php artisan test --coverage-clover=coverage.xml

If you want the workflow to continue even when tests fail (to still upload coverage), set continue-on-error: true for the step instead:

 - name: Generate Coverage Report
   if: always()
+  continue-on-error: true
   run: |
-    php artisan test --coverage-clover=coverage.xml || true
+    php artisan test --coverage-clover=coverage.xml
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Run Tests with Coverage
run: php artisan test --coverage --min=${{ env.MIN_COVERAGE }}
- name: Generate Coverage Report
if: always()
run: |
php artisan test --coverage-clover=coverage.xml || true
- name: Run Tests with Coverage
run: php artisan test --coverage --min=${{ env.MIN_COVERAGE }}
- name: Generate Coverage Report
if: always()
run: |
php artisan test --coverage-clover=coverage.xml
🤖 Prompt for AI Agents
.github/workflows/tests.yml lines 52-58: the post-test coverage-report step
includes `|| true` which masks failures and undermines the coverage enforcement;
remove the `|| true` from the `php artisan test --coverage-clover=coverage.xml`
command so the step fails when tests/coverage fail, and if you still want the
workflow to proceed despite failures use `continue-on-error: true` on the step
instead of silencing the command.


- name: Upload Coverage to Codecov
if: always()
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
fail_ci_if_error: false
verbose: true
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
8 changes: 0 additions & 8 deletions app/Http/Controllers/AgentController.php

This file was deleted.

9 changes: 6 additions & 3 deletions app/Http/Controllers/ArtifactController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
use App\Models\Artifact;
use App\Models\Chat;
use App\Services\SvgSanitizer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;

class ArtifactController extends Controller
{
use AuthorizesRequests;

/**
* Get all artifacts for a chat.
*/
public function index(Request $request, Chat $chat): JsonResponse
{
abort_unless($chat->user_id === $request->user()->id, 403);
$this->authorize('view', $chat);

$artifacts = Artifact::query()
->whereIn('message_id', $chat->messages()->pluck('id'))
Expand All @@ -36,7 +39,7 @@ public function show(Request $request, Artifact $artifact): JsonResponse
{
$chat = $artifact->message?->chat;
abort_if($chat === null, 404);
abort_unless($chat->user_id === $request->user()?->id, 403);
$this->authorize('view', $chat);

return response()->json($artifact);
}
Expand All @@ -48,7 +51,7 @@ public function render(Request $request, Artifact $artifact): Response
{
$chat = $artifact->message?->chat;
abort_if($chat === null, 404);
abort_unless($chat->user_id === $request->user()?->id, 403);
$this->authorize('view', $chat);

$view = $this->getRendererView($artifact);
$html = $view->render();
Expand Down
7 changes: 5 additions & 2 deletions app/Http/Controllers/ChatController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
use App\Http\Requests\StoreChatRequest;
use App\Http\Requests\UpdateChatRequest;
use App\Models\Chat;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class ChatController extends Controller
{
use AuthorizesRequests;

public function index(Request $request): Response
{
$chats = $request->user()->chats()->with('aiModel')->orderByDesc('updated_at')->get();
Expand All @@ -36,7 +39,7 @@ public function store(StoreChatRequest $request): RedirectResponse

public function show(Request $request, Chat $chat): Response
{
abort_unless($chat->user_id === $request->user()->id, 403);
$this->authorize('view', $chat);

$chats = $request->user()->chats()->with('aiModel')->orderByDesc('updated_at')->get();

Expand All @@ -56,7 +59,7 @@ public function update(UpdateChatRequest $request, Chat $chat): RedirectResponse

public function destroy(Request $request, Chat $chat): RedirectResponse
{
abort_unless($chat->user_id === $request->user()->id, 403);
$this->authorize('delete', $chat);

$chat->delete();

Expand Down
5 changes: 4 additions & 1 deletion app/Http/Controllers/ChatStreamController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@
use App\Models\AiModel;
use App\Models\Chat;
use App\Services\ChatStreamService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ChatStreamController extends Controller
{
use AuthorizesRequests;

public function __construct(
private readonly ChatStreamService $streamService
) {}

public function __invoke(ChatStreamRequest $request, Chat $chat): StreamedResponse
{
abort_unless($chat->user_id === $request->user()->id, 403);
$this->authorize('stream', $chat);

$userMessage = $request->string('message')->trim()->value();

Expand Down
5 changes: 5 additions & 0 deletions app/Http/Requests/ChatStreamRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ public function authorize(): bool
}

/**
* Get the validation rules that apply to the request.
*
* Note: ai_model_id is nullable here because existing chats have a default model.
* The controller falls back to the chat's ai_model_id when not provided.
*
* @return array<string, mixed>
*/
public function rules(): array
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/StoreAgentRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class StoreAgentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return auth()->check();
}

/**
Expand Down
2 changes: 2 additions & 0 deletions app/Jobs/GenerateChatTitle.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public function __construct(

public function handle(): void
{
$this->chat->loadMissing('aiModel');

$messages = $this->chat->messages()
->orderByDesc('created_at')
->limit(6)
Expand Down
47 changes: 47 additions & 0 deletions app/Models/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Agent extends Model
{
Expand All @@ -26,4 +29,48 @@ class Agent extends Model
'capabilities',
'is_active',
];

/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'tools' => 'array',
'capabilities' => 'array',
'is_active' => 'boolean',
];
}

/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

/**
* @return BelongsTo<AiModel, $this>
*/
public function defaultModel(): BelongsTo
{
return $this->belongsTo(AiModel::class, 'default_model_id');
}

/**
* @return BelongsToMany<AiModel, $this>
*/
public function models(): BelongsToMany
{
return $this->belongsToMany(AiModel::class, 'agent_model');
}

/**
* @return HasMany<Chat, $this>
*/
public function chats(): HasMany
{
return $this->hasMany(Chat::class);
}
}
11 changes: 11 additions & 0 deletions app/Models/AiModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ public function scopeEnabled(Builder $query): Builder
return $query->where('enabled', true);
}

/**
* Scope to get models that are both enabled and available.
*
* @param Builder<AiModel> $query
* @return Builder<AiModel>
*/
public function scopeAvailable(Builder $query): Builder
{
return $query->where('enabled', true)->where('is_available', true);
}

/**
* @param Builder<AiModel> $query
* @return Builder<AiModel>
Expand Down
9 changes: 9 additions & 0 deletions app/Models/Chat.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Chat extends Model
'user_id',
'title',
'ai_model_id',
'agent_id',
];

/**
Expand All @@ -49,4 +50,12 @@ public function aiModel(): BelongsTo
{
return $this->belongsTo(AiModel::class);
}

/**
* @return BelongsTo<Agent, $this>
*/
public function agent(): BelongsTo
{
return $this->belongsTo(Agent::class);
}
}
8 changes: 8 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public function chats(): \Illuminate\Database\Eloquent\Relations\HasMany
return $this->hasMany(Chat::class);
}

/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany<Agent, $this>
*/
public function agents(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Agent::class);
}

/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany<UserApiCredential, $this>
*/
Expand Down
51 changes: 51 additions & 0 deletions app/Policies/AgentPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Policies;

use App\Models\Agent;
use App\Models\User;

class AgentPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}

/**
* Determine whether the user can view the model.
*/
public function view(User $user, Agent $agent): bool
{
return $user->id === $agent->user_id;
}

/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}

/**
* Determine whether the user can update the model.
*/
public function update(User $user, Agent $agent): bool
{
return $user->id === $agent->user_id;
}

/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Agent $agent): bool
{
return $user->id === $agent->user_id;
}
}
Loading