From 4a241054cbdd5b4760cc70a7aabd606b237a4738 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 13:38:43 -0400 Subject: [PATCH 1/5] feat: add workspace chat agent participants --- app/Http/Controllers/ChatController.php | 8 ++ app/Http/Controllers/HomeController.php | 40 ++++-- .../Requests/StoreWorkspaceChatRequest.php | 11 ++ app/Models/Workspace.php | 8 ++ app/Models/WorkspaceAgent.php | 38 ++++++ app/Models/WorkspaceChatParticipant.php | 10 +- app/Support/Chats/WorkspaceAgentManager.php | 77 +++++++++++ app/Support/Chats/WorkspaceChatManager.php | 76 ++++++++++- .../Connections/InstanceConnectionManager.php | 9 ++ database/factories/WorkspaceAgentFactory.php | 43 ++++++ .../WorkspaceChatParticipantFactory.php | 14 ++ ...7_152146_create_workspace_agents_table.php | 42 ++++++ ...d_to_workspace_chat_participants_table.php | 46 +++++++ ...pair_workspace_agents_agent_key_column.php | 68 ++++++++++ ...key_column_from_workspace_agents_table.php | 40 ++++++ resources/views/welcome.blade.php | 41 +++++- tests/Feature/DesktopShellTest.php | 2 + tests/Feature/SurrealWorkspaceModelTest.php | 91 +++++++++++++ .../Feature/WorkspaceAgentParticipantTest.php | 122 ++++++++++++++++++ tests/Feature/WorkspaceChatManagementTest.php | 59 +++++++++ 20 files changed, 830 insertions(+), 15 deletions(-) create mode 100644 app/Models/WorkspaceAgent.php create mode 100644 app/Support/Chats/WorkspaceAgentManager.php create mode 100644 database/factories/WorkspaceAgentFactory.php create mode 100644 database/migrations/2026_03_27_152146_create_workspace_agents_table.php create mode 100644 database/migrations/2026_03_27_152203_add_workspace_agent_id_to_workspace_chat_participants_table.php create mode 100644 database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php create mode 100644 database/migrations/2026_03_27_160806_drop_legacy_key_column_from_workspace_agents_table.php create mode 100644 tests/Feature/WorkspaceAgentParticipantTest.php diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index f7e24fd..2892a7d 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -19,6 +19,13 @@ public function store( ViewerIdentityResolver $viewerIdentityResolver, WorkspaceChatManager $chatManager, ): RedirectResponse { + $submittedToken = (string) $request->validated('chat_submission_token'); + $expectedToken = $request->session()->pull('chat.create_token'); + + if (! is_string($expectedToken) || ! hash_equals($expectedToken, $submittedToken)) { + return to_route('home'); + } + $activeConnection = $connectionManager->activeConnectionFor( $request->user(), $request->root(), @@ -36,6 +43,7 @@ public function store( $chatManager->createChat($activeWorkspace, $request->user(), $viewerIdentity, [ 'name' => $request->validated('chat_name'), 'kind' => $request->validated('chat_kind'), + 'workspace_agent_id' => $request->integer('workspace_agent_id') ?: null, ]); return to_route('home'); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index d9320a2..bc619ab 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -6,10 +6,12 @@ use App\Models\InstanceConnection; use App\Models\SurrealWorkspace; use App\Models\Workspace; +use App\Models\WorkspaceAgent; use App\Models\WorkspaceChat; use App\Models\WorkspaceChatMessage; use App\Models\WorkspaceChatParticipant; use App\Services\Surreal\SurrealRuntimeManager; +use App\Support\Chats\WorkspaceAgentManager; use App\Support\Chats\WorkspaceChatManager; use App\Support\Connections\InstanceConnectionManager; use App\Support\Connections\ViewerIdentityResolver; @@ -17,6 +19,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Illuminate\View\View; use RuntimeException; use Throwable; @@ -310,18 +313,29 @@ private function roomLinks(InstanceConnection $activeConnection, string $activeR /** * @param EloquentCollection $chats - * @return array + * @return array */ private function chatLinks(EloquentCollection $chats, WorkspaceChat $activeChat): array { return $chats - ->map(fn (WorkspaceChat $chat): array => [ - 'label' => $chat->name, - 'active' => (int) $chat->getKey() === (int) $activeChat->getKey(), - 'prefix' => $chat->kind === WorkspaceChat::KIND_DIRECT ? '@' : strtoupper(substr($chat->name, 0, 1)), - 'tone' => $chat->kind === WorkspaceChat::KIND_DIRECT ? 'human' : 'room', - 'action' => (int) $chat->getKey() === (int) $activeChat->getKey() ? null : route('chats.activate', $chat), - ]) + ->map(function (WorkspaceChat $chat) use ($activeChat): array { + $hasAgentParticipant = $chat->participants->contains( + fn (WorkspaceChatParticipant $participant): bool => $participant->participant_type === WorkspaceChatParticipant::TYPE_AGENT, + ); + + return [ + 'label' => $chat->name, + 'active' => (int) $chat->getKey() === (int) $activeChat->getKey(), + 'prefix' => $hasAgentParticipant + ? 'AI' + : ($chat->kind === WorkspaceChat::KIND_DIRECT ? '@' : strtoupper(substr($chat->name, 0, 1))), + 'tone' => $hasAgentParticipant + ? 'bot' + : ($chat->kind === WorkspaceChat::KIND_DIRECT ? 'human' : 'room'), + 'meta' => $hasAgentParticipant ? 'Agent' : null, + 'action' => (int) $chat->getKey() === (int) $activeChat->getKey() ? null : route('chats.activate', $chat), + ]; + }) ->values() ->all(); } @@ -483,6 +497,7 @@ public function __invoke( SurrealRuntimeManager $runtimeManager, InstanceConnectionManager $connectionManager, ViewerIdentityResolver $viewerIdentityResolver, + WorkspaceAgentManager $workspaceAgentManager, WorkspaceChatManager $chatManager, ): View|RedirectResponse { $localReady = false; @@ -516,11 +531,15 @@ public function __invoke( $connections = $connectionManager->connectionsFor($request->user()); $workspaces = $connectionManager->workspacesFor($activeConnection); $activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces); + $availableAgents = $workspaceAgentManager->agentsFor($activeWorkspaceModel); $activeWorkspace = $this->activeWorkspaceState($activeConnection, $activeWorkspaceModel, $localReady); $activeChatModel = $chatManager->activeChatFor($activeWorkspaceModel, $request->user(), $viewerIdentity); $chats = $chatManager->chatsFor($activeWorkspaceModel); $participants = $this->chatParticipants($activeChatModel); $messages = $this->chatMessages($activeChatModel); + $chatSubmissionToken = (string) Str::uuid(); + + $request->session()->put('chat.create_token', $chatSubmissionToken); return view('welcome', [ 'mvpShellEnabled' => $mvpShellEnabled, @@ -536,6 +555,11 @@ public function __invoke( 'conversationNodeTabs' => $this->conversationNodeTabs($activeWorkspace), 'messages' => $messages, 'participants' => $participants, + 'chatSubmissionToken' => $chatSubmissionToken, + 'availableAgents' => $availableAgents->map(fn (WorkspaceAgent $workspaceAgent): array => [ + 'id' => (int) $workspaceAgent->getKey(), + 'name' => $workspaceAgent->name, + ])->values()->all(), 'viewerName' => $viewerIdentity['name'], 'viewerEmail' => $viewerIdentity['email'], 'viewerInitials' => $viewerIdentity['initials'], diff --git a/app/Http/Requests/StoreWorkspaceChatRequest.php b/app/Http/Requests/StoreWorkspaceChatRequest.php index 26491ee..dd704bc 100644 --- a/app/Http/Requests/StoreWorkspaceChatRequest.php +++ b/app/Http/Requests/StoreWorkspaceChatRequest.php @@ -12,6 +12,13 @@ public function authorize(): bool return $this->user() !== null; } + protected function prepareForValidation(): void + { + $this->merge([ + 'chat_name' => is_string($this->chat_name) ? trim($this->chat_name) : $this->chat_name, + ]); + } + /** * @return array|string> */ @@ -20,6 +27,8 @@ public function rules(): array return [ 'chat_name' => ['required', 'string', 'max:255', 'regex:/\\S/'], 'chat_kind' => ['required', 'string', 'in:direct,group'], + 'chat_submission_token' => ['required', 'string'], + 'workspace_agent_id' => ['nullable', 'integer', 'exists:workspace_agents,id'], ]; } @@ -33,6 +42,8 @@ public function messages(): array 'chat_name.regex' => 'Chat names cannot be blank.', 'chat_kind.required' => 'Choose whether this chat is direct or group.', 'chat_kind.in' => 'Chats must be direct or group conversations.', + 'chat_submission_token.required' => 'Refresh the workspace before creating a chat.', + 'workspace_agent_id.exists' => 'Choose a valid agent for this workspace.', ]; } } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index ff9181d..2f3d12f 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -40,4 +40,12 @@ public function chats(): HasMany { return $this->hasMany(WorkspaceChat::class); } + + /** + * @return HasMany + */ + public function agents(): HasMany + { + return $this->hasMany(WorkspaceAgent::class); + } } diff --git a/app/Models/WorkspaceAgent.php b/app/Models/WorkspaceAgent.php new file mode 100644 index 0000000..1fa7a78 --- /dev/null +++ b/app/Models/WorkspaceAgent.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return HasMany + */ + public function chatParticipants(): HasMany + { + return $this->hasMany(WorkspaceChatParticipant::class, 'workspace_agent_id'); + } +} diff --git a/app/Models/WorkspaceChatParticipant.php b/app/Models/WorkspaceChatParticipant.php index c3a3222..e60abbd 100644 --- a/app/Models/WorkspaceChatParticipant.php +++ b/app/Models/WorkspaceChatParticipant.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable(['chat_id', 'user_id', 'participant_type', 'participant_key', 'display_name'])] +#[Fillable(['chat_id', 'user_id', 'workspace_agent_id', 'participant_type', 'participant_key', 'display_name'])] class WorkspaceChatParticipant extends Model { public const TYPE_HUMAN = 'human'; @@ -33,4 +33,12 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + /** + * @return BelongsTo + */ + public function agent(): BelongsTo + { + return $this->belongsTo(WorkspaceAgent::class, 'workspace_agent_id'); + } } diff --git a/app/Support/Chats/WorkspaceAgentManager.php b/app/Support/Chats/WorkspaceAgentManager.php new file mode 100644 index 0000000..c49c0b7 --- /dev/null +++ b/app/Support/Chats/WorkspaceAgentManager.php @@ -0,0 +1,77 @@ + + */ + private const DEFAULT_AGENTS = [ + [ + 'agent_key' => WorkspaceAgent::KEY_WORKSPACE_GUIDE, + 'name' => 'Workspace Guide', + 'agent_class' => WorkspaceAgent::CLASS_WORKSPACE_GUIDE, + 'summary' => 'Helps shape durable, graph-native collaboration inside this workspace.', + ], + ]; + + /** + * @return Collection + */ + public function agentsFor(Workspace $workspace): Collection + { + return $this->ensureDefaults($workspace); + } + + /** + * @return Collection + */ + public function ensureDefaults(Workspace $workspace): Collection + { + $existingAgents = $workspace->agents() + ->get() + ->keyBy('agent_key'); + + foreach (self::DEFAULT_AGENTS as $definition) { + $workspaceAgent = $existingAgents->get($definition['agent_key']); + + if ($workspaceAgent instanceof WorkspaceAgent) { + $workspaceAgent->forceFill([ + 'name' => $definition['name'], + 'agent_class' => $definition['agent_class'], + 'summary' => $definition['summary'], + ])->save(); + + continue; + } + + $workspace->agents()->create([ + 'agent_key' => $definition['agent_key'], + 'name' => $definition['name'], + 'agent_class' => $definition['agent_class'], + 'summary' => $definition['summary'], + ]); + } + + return $workspace->agents() + ->orderBy('name') + ->get() + ->values(); + } + + public function agentForWorkspace(Workspace $workspace, ?int $agentId): ?WorkspaceAgent + { + if ($agentId === null) { + return null; + } + + return $workspace->agents() + ->whereKey($agentId) + ->first(); + } +} diff --git a/app/Support/Chats/WorkspaceChatManager.php b/app/Support/Chats/WorkspaceChatManager.php index a275553..7afa7da 100644 --- a/app/Support/Chats/WorkspaceChatManager.php +++ b/app/Support/Chats/WorkspaceChatManager.php @@ -4,14 +4,20 @@ use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceAgent; use App\Models\WorkspaceChat; use App\Models\WorkspaceChatMessage; use App\Models\WorkspaceChatParticipant; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; class WorkspaceChatManager { + public function __construct( + private WorkspaceAgentManager $workspaceAgentManager, + ) {} + /** * @return Collection */ @@ -55,7 +61,7 @@ public function activeChatFor( /** * @param array{name: string, email: string, initials: string} $viewerIdentity - * @param array{name: string, kind: string} $attributes + * @param array{name: string, kind: string, workspace_agent_id?: int|null} $attributes */ public function createChat( Workspace $workspace, @@ -64,20 +70,32 @@ public function createChat( array $attributes, ): WorkspaceChat { $chatName = trim($attributes['name']); + + if ($chatName === '') { + throw ValidationException::withMessages([ + 'chat_name' => 'Chat names cannot be blank.', + ]); + } + $chatKind = $attributes['kind'] === WorkspaceChat::KIND_DIRECT ? WorkspaceChat::KIND_DIRECT : WorkspaceChat::KIND_GROUP; + $workspaceAgent = $this->workspaceAgentFor($workspace, $attributes['workspace_agent_id'] ?? null); $chat = $workspace->chats()->create([ 'name' => $chatName, 'slug' => $this->nextChatSlug($workspace, $chatName), 'kind' => $chatKind, 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, - 'summary' => $this->chatSummary($workspace, $chatName, $chatKind), + 'summary' => $this->chatSummary($workspace, $chatName, $chatKind, $workspaceAgent), ]); $chat->participants()->create($this->defaultParticipant($viewer, $viewerIdentity)); + if ($workspaceAgent instanceof WorkspaceAgent) { + $chat->participants()->create($this->agentParticipant($workspaceAgent)); + } + $workspace->forceFill([ 'active_chat_id' => $chat->getKey(), ])->save(); @@ -127,12 +145,27 @@ private function defaultParticipant(User $viewer, array $viewerIdentity): array { return [ 'user_id' => $viewer->getKey(), + 'workspace_agent_id' => null, 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, 'participant_key' => $this->participantKey($viewer, $viewerIdentity), 'display_name' => $viewerIdentity['name'], ]; } + /** + * @return array + */ + private function agentParticipant(WorkspaceAgent $workspaceAgent): array + { + return [ + 'user_id' => null, + 'workspace_agent_id' => $workspaceAgent->getKey(), + 'participant_type' => WorkspaceChatParticipant::TYPE_AGENT, + 'participant_key' => 'agent:'.$workspaceAgent->agent_key, + 'display_name' => $workspaceAgent->name, + ]; + } + /** * @param array{name: string, email: string, initials: string} $viewerIdentity */ @@ -162,9 +195,22 @@ private function nextChatSlug(Workspace $workspace, string $chatName): string return $slug; } - private function chatSummary(Workspace $workspace, string $chatName, string $chatKind): string - { + private function chatSummary( + Workspace $workspace, + string $chatName, + string $chatKind, + ?WorkspaceAgent $workspaceAgent = null, + ): string { if ($chatKind === WorkspaceChat::KIND_DIRECT) { + if ($workspaceAgent instanceof WorkspaceAgent) { + return sprintf( + '%s is a private direct chat with %s inside the %s workspace.', + $chatName, + $workspaceAgent->name, + $workspace->name, + ); + } + return sprintf( '%s is a private direct chat inside the %s workspace.', $chatName, @@ -172,10 +218,32 @@ private function chatSummary(Workspace $workspace, string $chatName, string $cha ); } + if ($workspaceAgent instanceof WorkspaceAgent) { + return sprintf( + '%s is a private group chat with %s inside the %s workspace.', + $chatName, + $workspaceAgent->name, + $workspace->name, + ); + } + return sprintf( '%s is a private group chat inside the %s workspace.', $chatName, $workspace->name, ); } + + private function workspaceAgentFor(Workspace $workspace, ?int $workspaceAgentId): ?WorkspaceAgent + { + $workspaceAgent = $this->workspaceAgentManager->agentForWorkspace($workspace, $workspaceAgentId); + + if ($workspaceAgentId !== null && ! $workspaceAgent instanceof WorkspaceAgent) { + throw ValidationException::withMessages([ + 'workspace_agent_id' => 'Choose a valid agent for this workspace.', + ]); + } + + return $workspaceAgent; + } } diff --git a/app/Support/Connections/InstanceConnectionManager.php b/app/Support/Connections/InstanceConnectionManager.php index 4a478e1..2df77f8 100644 --- a/app/Support/Connections/InstanceConnectionManager.php +++ b/app/Support/Connections/InstanceConnectionManager.php @@ -5,6 +5,7 @@ use App\Models\InstanceConnection; use App\Models\User; use App\Models\Workspace; +use App\Support\Chats\WorkspaceAgentManager; use Illuminate\Contracts\Session\Session; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Str; @@ -15,6 +16,10 @@ class InstanceConnectionManager private const DEFAULT_WORKSPACE_NAME = 'General'; + public function __construct( + private WorkspaceAgentManager $workspaceAgentManager, + ) {} + /** * @return Collection */ @@ -160,6 +165,8 @@ public function activeWorkspaceFor(InstanceConnection $connection, ?Collection $ ])->save(); } + $this->workspaceAgentManager->ensureDefaults($activeWorkspace); + return $activeWorkspace; } @@ -175,6 +182,8 @@ public function createWorkspace(InstanceConnection $connection, array $attribute 'summary' => $this->workspaceSummary($connection, $workspaceName), ]); + $this->workspaceAgentManager->ensureDefaults($workspace); + $connection->forceFill([ 'active_workspace_id' => $workspace->getKey(), ])->save(); diff --git a/database/factories/WorkspaceAgentFactory.php b/database/factories/WorkspaceAgentFactory.php new file mode 100644 index 0000000..f50b542 --- /dev/null +++ b/database/factories/WorkspaceAgentFactory.php @@ -0,0 +1,43 @@ + + */ +class WorkspaceAgentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = str(fake()->unique()->words(2, true))->title()->append(' Agent')->value(); + + return [ + 'workspace_id' => Workspace::factory(), + 'agent_key' => Str::slug($name), + 'name' => $name, + 'agent_class' => WorkspaceGuide::class, + 'summary' => fake()->sentence(), + ]; + } + + public function workspaceGuide(): static + { + return $this->state(fn (): array => [ + 'agent_key' => WorkspaceAgent::KEY_WORKSPACE_GUIDE, + 'name' => 'Workspace Guide', + 'agent_class' => WorkspaceAgent::CLASS_WORKSPACE_GUIDE, + 'summary' => 'Helps shape durable, graph-native collaboration inside this workspace.', + ]); + } +} diff --git a/database/factories/WorkspaceChatParticipantFactory.php b/database/factories/WorkspaceChatParticipantFactory.php index d59615d..2e277da 100644 --- a/database/factories/WorkspaceChatParticipantFactory.php +++ b/database/factories/WorkspaceChatParticipantFactory.php @@ -3,6 +3,7 @@ namespace Database\Factories; use App\Models\User; +use App\Models\WorkspaceAgent; use App\Models\WorkspaceChat; use App\Models\WorkspaceChatParticipant; use Illuminate\Database\Eloquent\Factories\Factory; @@ -23,6 +24,7 @@ public function definition(): array return [ 'chat_id' => WorkspaceChat::factory(), 'user_id' => User::factory(), + 'workspace_agent_id' => null, 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, 'participant_key' => 'human:'.fake()->unique()->safeEmail(), 'display_name' => fake()->name(), @@ -33,9 +35,21 @@ public function agent(): static { return $this->state(fn (): array => [ 'user_id' => null, + 'workspace_agent_id' => null, 'participant_type' => WorkspaceChatParticipant::TYPE_AGENT, 'participant_key' => 'agent:'.Str::slug(fake()->unique()->words(2, true)), 'display_name' => str(fake()->unique()->words(2, true))->title()->append(' Agent')->value(), ]); } + + public function forAgent(WorkspaceAgent $agent): static + { + return $this->state(fn (): array => [ + 'user_id' => null, + 'workspace_agent_id' => $agent->getKey(), + 'participant_type' => WorkspaceChatParticipant::TYPE_AGENT, + 'participant_key' => 'agent:'.$agent->agent_key, + 'display_name' => $agent->name, + ]); + } } diff --git a/database/migrations/2026_03_27_152146_create_workspace_agents_table.php b/database/migrations/2026_03_27_152146_create_workspace_agents_table.php new file mode 100644 index 0000000..79091ce --- /dev/null +++ b/database/migrations/2026_03_27_152146_create_workspace_agents_table.php @@ -0,0 +1,42 @@ +getDriverName(); + + Schema::create('workspace_agents', function (Blueprint $table) use ($driver): void { + $table->id(); + if ($driver === 'surreal') { + $table->unsignedBigInteger('workspace_id'); + } else { + $table->foreignId('workspace_id')->constrained('connection_workspaces')->cascadeOnDelete(); + } + $table->string('agent_key'); + $table->string('name'); + $table->string('agent_class'); + $table->text('summary')->nullable(); + $table->timestamps(); + + if ($driver !== 'surreal') { + $table->unique(['workspace_id', 'agent_key'], 'workspace_agents_workspace_agent_key_unique'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_agents'); + } +}; diff --git a/database/migrations/2026_03_27_152203_add_workspace_agent_id_to_workspace_chat_participants_table.php b/database/migrations/2026_03_27_152203_add_workspace_agent_id_to_workspace_chat_participants_table.php new file mode 100644 index 0000000..dd4f654 --- /dev/null +++ b/database/migrations/2026_03_27_152203_add_workspace_agent_id_to_workspace_chat_participants_table.php @@ -0,0 +1,46 @@ +getDriverName(); + + Schema::table('workspace_chat_participants', function (Blueprint $table) use ($driver): void { + if ($driver === 'surreal') { + $table->unsignedBigInteger('workspace_agent_id')->nullable()->after('user_id'); + } else { + $table->foreignId('workspace_agent_id') + ->nullable() + ->after('user_id') + ->constrained('workspace_agents') + ->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + + Schema::table('workspace_chat_participants', function (Blueprint $table) use ($driver): void { + if ($driver === 'surreal') { + $table->dropColumn('workspace_agent_id'); + + return; + } + + $table->dropConstrainedForeignId('workspace_agent_id'); + }); + } +}; diff --git a/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php b/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php new file mode 100644 index 0000000..278392d --- /dev/null +++ b/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php @@ -0,0 +1,68 @@ +getDriverName(); + + Schema::table('workspace_agents', function (Blueprint $table): void { + $table->string('agent_key')->nullable()->after('workspace_id'); + }); + + $connection = DB::connection(); + $workspaceAgents = $connection->table('workspace_agents')->get(); + + foreach ($workspaceAgents as $workspaceAgent) { + $agentKey = $workspaceAgent->key ?? null; + + if (! is_string($agentKey) || trim($agentKey) === '') { + continue; + } + + $connection->table('workspace_agents') + ->where('id', $workspaceAgent->id) + ->update(['agent_key' => $agentKey]); + } + + if ($driver !== 'surreal') { + Schema::table('workspace_agents', function (Blueprint $table): void { + $table->unique(['workspace_id', 'agent_key'], 'workspace_agents_workspace_agent_key_unique'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('workspace_agents') || ! Schema::hasColumn('workspace_agents', 'agent_key')) { + return; + } + + $driver = Schema::getConnection()->getDriverName(); + + if ($driver !== 'surreal') { + Schema::table('workspace_agents', function (Blueprint $table): void { + $table->dropUnique('workspace_agents_workspace_agent_key_unique'); + }); + } + + Schema::table('workspace_agents', function (Blueprint $table): void { + $table->dropColumn('agent_key'); + }); + } +}; diff --git a/database/migrations/2026_03_27_160806_drop_legacy_key_column_from_workspace_agents_table.php b/database/migrations/2026_03_27_160806_drop_legacy_key_column_from_workspace_agents_table.php new file mode 100644 index 0000000..5fd727d --- /dev/null +++ b/database/migrations/2026_03_27_160806_drop_legacy_key_column_from_workspace_agents_table.php @@ -0,0 +1,40 @@ +dropColumn('key'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('workspace_agents') || Schema::hasColumn('workspace_agents', 'key')) { + return; + } + + Schema::table('workspace_agents', function (Blueprint $table): void { + $table->string('key')->nullable()->after('workspace_id'); + }); + } +}; diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index f58ef8f..56c9c34 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -114,6 +114,7 @@ class="shell-icon-button inline-flex h-7 w-7 items-center justify-center rounded :tone="$item['tone']" :active="$item['active'] ?? false" :muted="$item['muted'] ?? false" + :meta="$item['meta'] ?? null" :action="$item['action'] ?? null" /> @endforeach @@ -376,7 +377,10 @@ class="shell-icon-button inline-flex h-8 items-center justify-center rounded-ful {{ $participantPrefix }} - {{ $participant['label'] }} + + {{ $participant['label'] }} + {{ $participant['meta'] }} + @endforeach @@ -767,8 +771,9 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no -
+ @csrf +
@@ -777,6 +782,7 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no name="chat_name" type="text" value="{{ old('chat_name') }}" + required class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-none placeholder:text-[color:var(--shell-text-faint)]" placeholder="Design review" /> @@ -800,6 +806,25 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no @enderror
+ @if (count($availableAgents) > 0) +
+ + + @error('workspace_agent_id') +

{{ $message }}

+ @enderror +
+ @endif +

Chats stay private to this workspace and are kept out of the shared workspace graph.

@@ -839,6 +864,7 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no const rightRailCloseButtons = shell.querySelectorAll('[data-right-rail-close]'); const rightRailPinButtons = shell.querySelectorAll('[data-right-rail-pin]'); const rightRailResizeHandle = shell.querySelector('[data-right-rail-resize-handle]'); + const singleSubmitForms = document.querySelectorAll('[data-submit-once]'); const modals = document.querySelectorAll('[data-shell-modal]'); const dialogButtons = document.querySelectorAll('[data-dialog-target]'); const dialogCloseButtons = document.querySelectorAll('[data-dialog-close]'); @@ -901,6 +927,17 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no messageInput.style.height = `${Math.min(messageInput.scrollHeight, 160)}px`; }; + singleSubmitForms.forEach((form) => { + form.addEventListener('submit', () => { + const submitButtons = form.querySelectorAll('button[type="submit"]'); + + submitButtons.forEach((button) => { + button.disabled = true; + button.classList.add('opacity-60', 'pointer-events-none'); + }); + }); + }); + const openSearchOverlay = () => { searchOverlay?.classList.remove('hidden'); searchBackdrop?.classList.remove('hidden'); diff --git a/tests/Feature/DesktopShellTest.php b/tests/Feature/DesktopShellTest.php index c510b84..76c9d80 100644 --- a/tests/Feature/DesktopShellTest.php +++ b/tests/Feature/DesktopShellTest.php @@ -115,10 +115,12 @@ function desktopShellUser(): User ->assertSee('Create workspace') ->assertSee('Create room') ->assertSee('Create chat') + ->assertSee('Agent participant') ->assertSee('Product Atlas') ->assertSee('General') ->assertSee('Design Review') ->assertSee('Founder Sync') + ->assertSee('Workspace Guide') ->assertSee('Morgan Hale') ->assertSee('# general') ->assertSee('Connections') diff --git a/tests/Feature/SurrealWorkspaceModelTest.php b/tests/Feature/SurrealWorkspaceModelTest.php index 81a4cb6..00884d2 100644 --- a/tests/Feature/SurrealWorkspaceModelTest.php +++ b/tests/Feature/SurrealWorkspaceModelTest.php @@ -2,11 +2,13 @@ use App\Models\SurrealWorkspace; use App\Models\Workspace; +use App\Models\WorkspaceAgent; use App\Services\Surreal\SurrealCliClient; use App\Services\Surreal\SurrealConnection; use App\Services\Surreal\SurrealDocumentStore; use App\Services\Surreal\SurrealHttpClient; use App\Services\Surreal\SurrealRuntimeManager; +use App\Support\Chats\WorkspaceAgentManager; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -249,6 +251,95 @@ } }); +test('workspace agent repair drops the legacy key field before seeding defaults', function () { + $client = app(SurrealCliClient::class); + + if (! $client->isAvailable()) { + $this->markTestSkipped('The `surreal` CLI is not available in this environment.'); + } + + $storagePath = storage_path('app/surrealdb/workspace-agents-repair-test-'.Str::uuid()); + $originalDefaultConnection = config('database.default'); + + File::deleteDirectory($storagePath); + File::ensureDirectoryExists(dirname($storagePath)); + + try { + $server = retryStartingWorkspaceServer($client, $storagePath); + $port = $server['port']; + $endpoint = $server['endpoint']; + + config()->set('database.default', 'surreal'); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', $port); + config()->set('surreal.endpoint', $endpoint); + config()->set('surreal.username', 'root'); + config()->set('surreal.password', 'root'); + config()->set('surreal.namespace', 'katra'); + config()->set('surreal.database', 'workspace_agents_repair_test'); + config()->set('surreal.storage_engine', 'surrealkv'); + config()->set('surreal.storage_path', $storagePath); + config()->set('surreal.runtime', 'local'); + config()->set('surreal.autostart', false); + + DB::purge('surreal'); + + $schema = Schema::connection('surreal'); + + $schema->create('connection_workspaces', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('instance_connection_id'); + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + $schema->create('workspace_agents', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id'); + $table->string('key'); + $table->string('name'); + $table->string('agent_class'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + $workspace = Workspace::create([ + 'instance_connection_id' => 42, + 'name' => 'Atlas', + 'slug' => 'atlas', + 'summary' => 'Workspace ready for agent repair testing.', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $renameMigration = require database_path('migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php'); + $renameMigration->up(); + + $dropLegacyMigration = require database_path('migrations/2026_03_27_160806_drop_legacy_key_column_from_workspace_agents_table.php'); + $dropLegacyMigration->up(); + + $agents = app(WorkspaceAgentManager::class)->agentsFor($workspace->fresh()); + + expect($schema->hasColumn('workspace_agents', 'agent_key'))->toBeTrue() + ->and($schema->hasColumn('workspace_agents', 'key'))->toBeFalse() + ->and($agents)->toHaveCount(1) + ->and($agents->first()?->agent_key)->toBe(WorkspaceAgent::KEY_WORKSPACE_GUIDE) + ->and($agents->first()?->name)->toBe('Workspace Guide'); + } finally { + config()->set('database.default', $originalDefaultConnection); + + DB::purge('surreal'); + + if (isset($server['process'])) { + $server['process']->stop(1); + } + + File::deleteDirectory($storagePath); + } +}); + /** * @return array{endpoint: string, port: int, process: Process} */ diff --git a/tests/Feature/WorkspaceAgentParticipantTest.php b/tests/Feature/WorkspaceAgentParticipantTest.php new file mode 100644 index 0000000..edc4181 --- /dev/null +++ b/tests/Feature/WorkspaceAgentParticipantTest.php @@ -0,0 +1,122 @@ +create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create(); + + $workspace = app(InstanceConnectionManager::class)->createWorkspace($connection, [ + 'name' => 'Product Atlas', + ]); + $agents = app(WorkspaceAgentManager::class)->agentsFor($workspace); + $reloadedAgents = app(WorkspaceAgentManager::class)->agentsFor($workspace); + + expect($agents)->toHaveCount(1) + ->and($agents->first()?->agent_key)->toBe(WorkspaceAgent::KEY_WORKSPACE_GUIDE) + ->and($agents->first()?->name)->toBe('Workspace Guide') + ->and($agents->first()?->agent_class)->toBe(WorkspaceAgent::CLASS_WORKSPACE_GUIDE) + ->and($reloadedAgents)->toHaveCount(1); +}); + +test('an authenticated user can create a private chat with a workspace agent participant', function () { + $user = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'base_url' => 'https://katra.test', + ]); + $workspace = Workspace::factory()->for($connection)->create([ + 'name' => 'Product Atlas', + ]); + $workspaceGuide = app(WorkspaceAgentManager::class)->agentsFor($workspace)->first(); + $token = (string) Str::uuid(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + 'chat.create_token' => $token, + ]); + + post(route('chats.store'), [ + 'chat_name' => 'Workspace Guide', + 'chat_kind' => WorkspaceChat::KIND_DIRECT, + 'chat_submission_token' => $token, + 'workspace_agent_id' => $workspaceGuide?->getKey(), + ])->assertRedirect(route('home')); + + $chat = $workspace->fresh()->chats()->latest('id')->first(); + + expect($chat)->not()->toBeNull() + ->and($chat?->summary)->toContain('Workspace Guide') + ->and($chat?->participants()->count())->toBe(2); + + $agentParticipant = $chat?->participants() + ->where('participant_type', WorkspaceChatParticipant::TYPE_AGENT) + ->first(); + + expect($agentParticipant)->not()->toBeNull() + ->and($agentParticipant?->agent?->name)->toBe('Workspace Guide') + ->and($agentParticipant?->display_name)->toBe('Workspace Guide'); +}); + +test('the shell renders agent-backed chats distinctly from human-only chats', function () { + $user = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + ]); + $workspace = Workspace::factory()->for($connection)->create([ + 'name' => 'Product Atlas', + ]); + $workspaceGuide = WorkspaceAgent::factory()->workspaceGuide()->for($workspace)->create(); + $chat = WorkspaceChat::factory()->for($workspace, 'workspace')->direct()->create([ + 'name' => 'Workspace Guide', + 'summary' => 'Workspace Guide is a private direct chat inside Product Atlas.', + ]); + + $workspace->forceFill([ + 'active_chat_id' => $chat->getKey(), + ])->save(); + $connection->forceFill([ + 'active_workspace_id' => $workspace->getKey(), + ])->save(); + + WorkspaceChatParticipant::factory()->for($chat, 'chat')->create([ + 'user_id' => $user->getKey(), + 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, + 'participant_key' => 'human:derek@katra.io', + 'display_name' => 'Derek Bourgeois', + ]); + WorkspaceChatParticipant::factory()->for($chat, 'chat')->forAgent($workspaceGuide)->create(); + + actingAs($user); + + get('/') + ->assertSuccessful() + ->assertSee('Workspace Guide') + ->assertSee('Agent participant') + ->assertSee('Agent'); +}); diff --git a/tests/Feature/WorkspaceChatManagementTest.php b/tests/Feature/WorkspaceChatManagementTest.php index 2d943cb..39ed296 100644 --- a/tests/Feature/WorkspaceChatManagementTest.php +++ b/tests/Feature/WorkspaceChatManagementTest.php @@ -8,6 +8,7 @@ use App\Models\WorkspaceChatParticipant; use App\Support\Chats\WorkspaceChatManager; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use function Pest\Laravel\actingAs; use function Pest\Laravel\post; @@ -40,6 +41,7 @@ 'base_url' => 'https://katra.test', ]); $workspace = Workspace::factory()->for($connection)->create(); + $token = (string) Str::uuid(); $workspace->forceFill([ 'active_chat_id' => null, @@ -47,11 +49,13 @@ actingAs($user)->withSession([ 'instance_connection.active_id' => $connection->getKey(), + 'chat.create_token' => $token, ]); post(route('chats.store'), [ 'chat_name' => $kind === WorkspaceChat::KIND_DIRECT ? 'Morgan' : 'Design Review', 'chat_kind' => $kind, + 'chat_submission_token' => $token, ])->assertRedirect(route('home')); $chat = $workspace->fresh()->chats()->latest('id')->first(); @@ -131,3 +135,58 @@ ->and($otherWorkspace->chats()->count())->toBe(0) ->and($chat->visibility)->toBe(WorkspaceChat::VISIBILITY_PRIVATE); }); + +test('an authenticated user cannot create a private chat with a blank name', function (string $chatName) { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'base_url' => 'https://katra.test', + ]); + $workspace = Workspace::factory()->for($connection)->create(); + $token = (string) Str::uuid(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + 'chat.create_token' => $token, + ]); + + post(route('chats.store'), [ + 'chat_name' => $chatName, + 'chat_kind' => WorkspaceChat::KIND_GROUP, + 'chat_submission_token' => $token, + ])->assertSessionHasErrors('chat_name'); + + expect($workspace->fresh()->chats()->count())->toBe(0) + ->and($workspace->fresh()->active_chat_id)->toBeNull(); +})->with([ + '', + ' ', +]); + +test('duplicate chat submissions with the same token only create one chat', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'base_url' => 'https://katra.test', + ]); + $workspace = Workspace::factory()->for($connection)->create(); + $token = (string) Str::uuid(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + 'chat.create_token' => $token, + ]); + + post(route('chats.store'), [ + 'chat_name' => 'Design Review', + 'chat_kind' => WorkspaceChat::KIND_GROUP, + 'chat_submission_token' => $token, + ])->assertRedirect(route('home')); + + post(route('chats.store'), [ + 'chat_name' => 'Design Review', + 'chat_kind' => WorkspaceChat::KIND_GROUP, + 'chat_submission_token' => $token, + ])->assertRedirect(route('home')); + + expect($workspace->fresh()->chats()->count())->toBe(1) + ->and($workspace->fresh()->chats()->first()?->name)->toBe('Design Review'); +}); From 24091bd53f687f6367647e994a38e5d3a439786e Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 13:55:49 -0400 Subject: [PATCH 2/5] fix: harden workspace agent chat flows --- app/Http/Controllers/ChatController.php | 6 ++- app/Http/Controllers/HomeController.php | 6 +-- app/Models/WorkspaceChat.php | 12 +++++- app/Support/Chats/WorkspaceAgentManager.php | 11 ++++- app/Support/Chats/WorkspaceChatManager.php | 1 + ...t_participant_to_workspace_chats_table.php | 43 +++++++++++++++++++ tests/Feature/SurrealWorkspaceModelTest.php | 2 +- .../Feature/WorkspaceAgentParticipantTest.php | 4 +- tests/Feature/WorkspaceChatManagementTest.php | 34 +++++++++++++++ 9 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 2892a7d..5eacc83 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -20,7 +20,7 @@ public function store( WorkspaceChatManager $chatManager, ): RedirectResponse { $submittedToken = (string) $request->validated('chat_submission_token'); - $expectedToken = $request->session()->pull('chat.create_token'); + $expectedToken = $request->session()->get('chat.create_token'); if (! is_string($expectedToken) || ! hash_equals($expectedToken, $submittedToken)) { return to_route('home'); @@ -46,6 +46,10 @@ public function store( 'workspace_agent_id' => $request->integer('workspace_agent_id') ?: null, ]); + if (hash_equals((string) $request->session()->get('chat.create_token'), $submittedToken)) { + $request->session()->forget('chat.create_token'); + } + return to_route('home'); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index bc619ab..4b36e26 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -319,9 +319,7 @@ private function chatLinks(EloquentCollection $chats, WorkspaceChat $activeChat) { return $chats ->map(function (WorkspaceChat $chat) use ($activeChat): array { - $hasAgentParticipant = $chat->participants->contains( - fn (WorkspaceChatParticipant $participant): bool => $participant->participant_type === WorkspaceChatParticipant::TYPE_AGENT, - ); + $hasAgentParticipant = (bool) $chat->has_agent_participant; return [ 'label' => $chat->name, @@ -531,7 +529,7 @@ public function __invoke( $connections = $connectionManager->connectionsFor($request->user()); $workspaces = $connectionManager->workspacesFor($activeConnection); $activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces); - $availableAgents = $workspaceAgentManager->agentsFor($activeWorkspaceModel); + $availableAgents = $activeWorkspaceModel->agents; $activeWorkspace = $this->activeWorkspaceState($activeConnection, $activeWorkspaceModel, $localReady); $activeChatModel = $chatManager->activeChatFor($activeWorkspaceModel, $request->user(), $viewerIdentity); $chats = $chatManager->chatsFor($activeWorkspaceModel); diff --git a/app/Models/WorkspaceChat.php b/app/Models/WorkspaceChat.php index 29c2dbc..03dcf78 100644 --- a/app/Models/WorkspaceChat.php +++ b/app/Models/WorkspaceChat.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['workspace_id', 'name', 'slug', 'kind', 'visibility', 'summary'])] +#[Fillable(['workspace_id', 'name', 'slug', 'kind', 'visibility', 'summary', 'has_agent_participant'])] class WorkspaceChat extends Model { public const KIND_DIRECT = 'direct'; @@ -21,6 +21,16 @@ class WorkspaceChat extends Model /** @use HasFactory */ use HasFactory; + /** + * @return array + */ + protected function casts(): array + { + return [ + 'has_agent_participant' => 'boolean', + ]; + } + /** * @return BelongsTo */ diff --git a/app/Support/Chats/WorkspaceAgentManager.php b/app/Support/Chats/WorkspaceAgentManager.php index c49c0b7..2fd83be 100644 --- a/app/Support/Chats/WorkspaceAgentManager.php +++ b/app/Support/Chats/WorkspaceAgentManager.php @@ -25,7 +25,10 @@ class WorkspaceAgentManager */ public function agentsFor(Workspace $workspace): Collection { - return $this->ensureDefaults($workspace); + return $workspace->agents() + ->orderBy('name') + ->get() + ->values(); } /** @@ -45,7 +48,11 @@ public function ensureDefaults(Workspace $workspace): Collection 'name' => $definition['name'], 'agent_class' => $definition['agent_class'], 'summary' => $definition['summary'], - ])->save(); + ]); + + if ($workspaceAgent->isDirty()) { + $workspaceAgent->save(); + } continue; } diff --git a/app/Support/Chats/WorkspaceChatManager.php b/app/Support/Chats/WorkspaceChatManager.php index 7afa7da..8f32365 100644 --- a/app/Support/Chats/WorkspaceChatManager.php +++ b/app/Support/Chats/WorkspaceChatManager.php @@ -88,6 +88,7 @@ public function createChat( 'kind' => $chatKind, 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, 'summary' => $this->chatSummary($workspace, $chatName, $chatKind, $workspaceAgent), + 'has_agent_participant' => $workspaceAgent instanceof WorkspaceAgent, ]); $chat->participants()->create($this->defaultParticipant($viewer, $viewerIdentity)); diff --git a/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php b/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php new file mode 100644 index 0000000..68c8f56 --- /dev/null +++ b/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php @@ -0,0 +1,43 @@ +boolean('has_agent_participant')->default(false)->after('summary'); + }); + + $chatIdsWithAgents = DB::table('workspace_chat_participants') + ->where('participant_type', 'agent') + ->get(['chat_id']) + ->pluck('chat_id') + ->map(fn (mixed $chatId): int => (int) $chatId) + ->unique() + ->values(); + + foreach ($chatIdsWithAgents as $chatId) { + DB::table('workspace_chats') + ->where('id', $chatId) + ->update(['has_agent_participant' => true]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workspace_chats', function (Blueprint $table): void { + $table->dropColumn('has_agent_participant'); + }); + } +}; diff --git a/tests/Feature/SurrealWorkspaceModelTest.php b/tests/Feature/SurrealWorkspaceModelTest.php index 00884d2..e0dcc90 100644 --- a/tests/Feature/SurrealWorkspaceModelTest.php +++ b/tests/Feature/SurrealWorkspaceModelTest.php @@ -320,7 +320,7 @@ $dropLegacyMigration = require database_path('migrations/2026_03_27_160806_drop_legacy_key_column_from_workspace_agents_table.php'); $dropLegacyMigration->up(); - $agents = app(WorkspaceAgentManager::class)->agentsFor($workspace->fresh()); + $agents = app(WorkspaceAgentManager::class)->ensureDefaults($workspace->fresh()); expect($schema->hasColumn('workspace_agents', 'agent_key'))->toBeTrue() ->and($schema->hasColumn('workspace_agents', 'key'))->toBeFalse() diff --git a/tests/Feature/WorkspaceAgentParticipantTest.php b/tests/Feature/WorkspaceAgentParticipantTest.php index edc4181..fcf05c2 100644 --- a/tests/Feature/WorkspaceAgentParticipantTest.php +++ b/tests/Feature/WorkspaceAgentParticipantTest.php @@ -47,7 +47,7 @@ $workspace = Workspace::factory()->for($connection)->create([ 'name' => 'Product Atlas', ]); - $workspaceGuide = app(WorkspaceAgentManager::class)->agentsFor($workspace)->first(); + $workspaceGuide = app(WorkspaceAgentManager::class)->ensureDefaults($workspace)->first(); $token = (string) Str::uuid(); actingAs($user)->withSession([ @@ -66,6 +66,7 @@ expect($chat)->not()->toBeNull() ->and($chat?->summary)->toContain('Workspace Guide') + ->and($chat?->has_agent_participant)->toBeTrue() ->and($chat?->participants()->count())->toBe(2); $agentParticipant = $chat?->participants() @@ -95,6 +96,7 @@ $chat = WorkspaceChat::factory()->for($workspace, 'workspace')->direct()->create([ 'name' => 'Workspace Guide', 'summary' => 'Workspace Guide is a private direct chat inside Product Atlas.', + 'has_agent_participant' => true, ]); $workspace->forceFill([ diff --git a/tests/Feature/WorkspaceChatManagementTest.php b/tests/Feature/WorkspaceChatManagementTest.php index 39ed296..bd69867 100644 --- a/tests/Feature/WorkspaceChatManagementTest.php +++ b/tests/Feature/WorkspaceChatManagementTest.php @@ -3,6 +3,7 @@ use App\Models\InstanceConnection; use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceAgent; use App\Models\WorkspaceChat; use App\Models\WorkspaceChatMessage; use App\Models\WorkspaceChatParticipant; @@ -63,6 +64,7 @@ expect($chat)->not()->toBeNull() ->and($chat?->kind)->toBe($kind) ->and($chat?->visibility)->toBe(WorkspaceChat::VISIBILITY_PRIVATE) + ->and($chat?->has_agent_participant)->toBeFalse() ->and($workspace->fresh()->active_chat_id)->toBe($chat?->getKey()) ->and($chat?->participants()->count())->toBe(1); })->with([ @@ -190,3 +192,35 @@ expect($workspace->fresh()->chats()->count())->toBe(1) ->and($workspace->fresh()->chats()->first()?->name)->toBe('Design Review'); }); + +test('a failed agent chat submission can be corrected and resubmitted with the same token', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'base_url' => 'https://katra.test', + ]); + $workspace = Workspace::factory()->for($connection)->create(); + $otherWorkspace = Workspace::factory()->for($connection)->create(); + $token = (string) Str::uuid(); + $foreignAgent = WorkspaceAgent::factory()->workspaceGuide()->for($otherWorkspace)->create(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + 'chat.create_token' => $token, + ]); + + post(route('chats.store'), [ + 'chat_name' => 'Workspace Guide', + 'chat_kind' => WorkspaceChat::KIND_DIRECT, + 'chat_submission_token' => $token, + 'workspace_agent_id' => $foreignAgent->getKey(), + ])->assertSessionHasErrors('workspace_agent_id'); + + post(route('chats.store'), [ + 'chat_name' => 'Workspace Guide', + 'chat_kind' => WorkspaceChat::KIND_DIRECT, + 'chat_submission_token' => $token, + ])->assertRedirect(route('home')); + + expect($workspace->fresh()->chats()->count())->toBe(1) + ->and($workspace->fresh()->chats()->first()?->name)->toBe('Workspace Guide'); +}); From 3a50964585b7d3579e42e2059847886123eceefe Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 15:10:01 -0400 Subject: [PATCH 3/5] fix: tighten workspace agent defaults --- app/Http/Controllers/HomeController.php | 2 -- app/Support/Chats/WorkspaceAgentManager.php | 17 ++++++++++-- ...pair_workspace_agents_agent_key_column.php | 24 ++++++++++------- ...t_participant_to_workspace_chats_table.php | 26 +++++++++++-------- .../Feature/WorkspaceAgentParticipantTest.php | 17 ++++++++++++ 5 files changed, 61 insertions(+), 25 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 4b36e26..f696dab 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -11,7 +11,6 @@ use App\Models\WorkspaceChatMessage; use App\Models\WorkspaceChatParticipant; use App\Services\Surreal\SurrealRuntimeManager; -use App\Support\Chats\WorkspaceAgentManager; use App\Support\Chats\WorkspaceChatManager; use App\Support\Connections\InstanceConnectionManager; use App\Support\Connections\ViewerIdentityResolver; @@ -495,7 +494,6 @@ public function __invoke( SurrealRuntimeManager $runtimeManager, InstanceConnectionManager $connectionManager, ViewerIdentityResolver $viewerIdentityResolver, - WorkspaceAgentManager $workspaceAgentManager, WorkspaceChatManager $chatManager, ): View|RedirectResponse { $localReady = false; diff --git a/app/Support/Chats/WorkspaceAgentManager.php b/app/Support/Chats/WorkspaceAgentManager.php index 2fd83be..6f2a9b5 100644 --- a/app/Support/Chats/WorkspaceAgentManager.php +++ b/app/Support/Chats/WorkspaceAgentManager.php @@ -37,11 +37,14 @@ public function agentsFor(Workspace $workspace): Collection public function ensureDefaults(Workspace $workspace): Collection { $existingAgents = $workspace->agents() + ->orderBy('id') ->get() - ->keyBy('agent_key'); + ->groupBy('agent_key'); foreach (self::DEFAULT_AGENTS as $definition) { - $workspaceAgent = $existingAgents->get($definition['agent_key']); + $workspaceAgent = $existingAgents + ->get($definition['agent_key']) + ?->first(); if ($workspaceAgent instanceof WorkspaceAgent) { $workspaceAgent->forceFill([ @@ -54,6 +57,16 @@ public function ensureDefaults(Workspace $workspace): Collection $workspaceAgent->save(); } + $duplicateAgentIds = $existingAgents + ->get($definition['agent_key']) + ?->slice(1) + ->map(fn (WorkspaceAgent $duplicateAgent): int => (int) $duplicateAgent->getKey()) + ->all() ?? []; + + if ($duplicateAgentIds !== []) { + $workspace->agents()->whereKey($duplicateAgentIds)->delete(); + } + continue; } diff --git a/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php b/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php index 278392d..577a8c2 100644 --- a/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php +++ b/database/migrations/2026_03_27_155657_repair_workspace_agents_agent_key_column.php @@ -23,19 +23,23 @@ public function up(): void }); $connection = DB::connection(); - $workspaceAgents = $connection->table('workspace_agents')->get(); - foreach ($workspaceAgents as $workspaceAgent) { - $agentKey = $workspaceAgent->key ?? null; + $connection->table('workspace_agents') + ->select(['id', 'key']) + ->orderBy('id') + ->chunkById(100, function ($workspaceAgents) use ($connection): void { + foreach ($workspaceAgents as $workspaceAgent) { + $agentKey = $workspaceAgent->key ?? null; - if (! is_string($agentKey) || trim($agentKey) === '') { - continue; - } + if (! is_string($agentKey) || trim($agentKey) === '') { + continue; + } - $connection->table('workspace_agents') - ->where('id', $workspaceAgent->id) - ->update(['agent_key' => $agentKey]); - } + $connection->table('workspace_agents') + ->where('id', $workspaceAgent->id) + ->update(['agent_key' => $agentKey]); + } + }); if ($driver !== 'surreal') { Schema::table('workspace_agents', function (Blueprint $table): void { diff --git a/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php b/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php index 68c8f56..3c41708 100644 --- a/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php +++ b/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php @@ -16,19 +16,23 @@ public function up(): void $table->boolean('has_agent_participant')->default(false)->after('summary'); }); - $chatIdsWithAgents = DB::table('workspace_chat_participants') + DB::table('workspace_chat_participants') + ->select('chat_id') ->where('participant_type', 'agent') - ->get(['chat_id']) - ->pluck('chat_id') - ->map(fn (mixed $chatId): int => (int) $chatId) - ->unique() - ->values(); + ->distinct() + ->orderBy('chat_id') + ->chunk(100, function ($chatIdsWithAgents): void { + $chatIds = $chatIdsWithAgents + ->pluck('chat_id') + ->map(fn (mixed $chatId): int => (int) $chatId) + ->all(); - foreach ($chatIdsWithAgents as $chatId) { - DB::table('workspace_chats') - ->where('id', $chatId) - ->update(['has_agent_participant' => true]); - } + foreach ($chatIds as $chatId) { + DB::table('workspace_chats') + ->where('id', $chatId) + ->update(['has_agent_participant' => true]); + } + }); } /** diff --git a/tests/Feature/WorkspaceAgentParticipantTest.php b/tests/Feature/WorkspaceAgentParticipantTest.php index fcf05c2..b30969f 100644 --- a/tests/Feature/WorkspaceAgentParticipantTest.php +++ b/tests/Feature/WorkspaceAgentParticipantTest.php @@ -34,6 +34,23 @@ ->and($reloadedAgents)->toHaveCount(1); }); +test('ensuring default workspace agents keeps a single workspace guide up to date', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create(); + $workspace = Workspace::factory()->for($connection)->create(); + + WorkspaceAgent::factory()->workspaceGuide()->for($workspace)->create([ + 'summary' => 'Outdated guide summary', + ]); + + $agents = app(WorkspaceAgentManager::class)->ensureDefaults($workspace); + + expect($agents)->toHaveCount(1) + ->and($workspace->fresh()->agents()->count())->toBe(1) + ->and($agents->first()?->agent_key)->toBe(WorkspaceAgent::KEY_WORKSPACE_GUIDE) + ->and($agents->first()?->summary)->toBe('Helps shape durable, graph-native collaboration inside this workspace.'); +}); + test('an authenticated user can create a private chat with a workspace agent participant', function () { $user = User::factory()->create([ 'first_name' => 'Derek', From 1aba9462cb622953467a70871528086458dd8c59 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 15:12:05 -0400 Subject: [PATCH 4/5] test: pin active workspace in retry regression --- tests/Feature/WorkspaceChatManagementTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Feature/WorkspaceChatManagementTest.php b/tests/Feature/WorkspaceChatManagementTest.php index bd69867..4eadf99 100644 --- a/tests/Feature/WorkspaceChatManagementTest.php +++ b/tests/Feature/WorkspaceChatManagementTest.php @@ -202,6 +202,9 @@ $otherWorkspace = Workspace::factory()->for($connection)->create(); $token = (string) Str::uuid(); $foreignAgent = WorkspaceAgent::factory()->workspaceGuide()->for($otherWorkspace)->create(); + $connection->forceFill([ + 'active_workspace_id' => $workspace->getKey(), + ])->save(); actingAs($user)->withSession([ 'instance_connection.active_id' => $connection->getKey(), From 532c87a35e58b2ce3bc7de757bcd8dd59e9cadd8 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 15:23:58 -0400 Subject: [PATCH 5/5] fix: harden workspace agent review followups --- app/Http/Controllers/ChatController.php | 28 +++- app/Support/Chats/WorkspaceAgentManager.php | 21 ++- ...t_participant_to_workspace_chats_table.php | 4 +- tests/Feature/SurrealWorkspaceModelTest.php | 152 ++++++++++++++++++ 4 files changed, 195 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 5eacc83..624722b 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -10,6 +10,8 @@ use App\Support\Connections\ViewerIdentityResolver; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Validation\ValidationException; class ChatController extends Controller { @@ -26,6 +28,16 @@ public function store( return to_route('home'); } + $submissionTokenCacheKey = sprintf( + 'workspace-chat-create:%d:%s', + (int) $request->user()->getKey(), + sha1($submittedToken), + ); + + if (! Cache::add($submissionTokenCacheKey, true, now()->addMinutes(10))) { + return to_route('home'); + } + $activeConnection = $connectionManager->activeConnectionFor( $request->user(), $request->root(), @@ -40,11 +52,17 @@ public function store( $activeWorkspace = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces); $viewerIdentity = $viewerIdentityResolver->resolve($request->user(), $activeConnection); - $chatManager->createChat($activeWorkspace, $request->user(), $viewerIdentity, [ - 'name' => $request->validated('chat_name'), - 'kind' => $request->validated('chat_kind'), - 'workspace_agent_id' => $request->integer('workspace_agent_id') ?: null, - ]); + try { + $chatManager->createChat($activeWorkspace, $request->user(), $viewerIdentity, [ + 'name' => $request->validated('chat_name'), + 'kind' => $request->validated('chat_kind'), + 'workspace_agent_id' => $request->integer('workspace_agent_id') ?: null, + ]); + } catch (ValidationException $exception) { + Cache::forget($submissionTokenCacheKey); + + throw $exception; + } if (hash_equals((string) $request->session()->get('chat.create_token'), $submittedToken)) { $request->session()->forget('chat.create_token'); diff --git a/app/Support/Chats/WorkspaceAgentManager.php b/app/Support/Chats/WorkspaceAgentManager.php index 6f2a9b5..9543d6d 100644 --- a/app/Support/Chats/WorkspaceAgentManager.php +++ b/app/Support/Chats/WorkspaceAgentManager.php @@ -4,6 +4,7 @@ use App\Models\Workspace; use App\Models\WorkspaceAgent; +use App\Models\WorkspaceChatParticipant; use Illuminate\Database\Eloquent\Collection; class WorkspaceAgentManager @@ -57,14 +58,28 @@ public function ensureDefaults(Workspace $workspace): Collection $workspaceAgent->save(); } - $duplicateAgentIds = $existingAgents + $duplicateAgents = $existingAgents ->get($definition['agent_key']) ?->slice(1) + ->values() ?? collect(); + + $duplicateAgentIds = $duplicateAgents ->map(fn (WorkspaceAgent $duplicateAgent): int => (int) $duplicateAgent->getKey()) - ->all() ?? []; + ->all(); if ($duplicateAgentIds !== []) { - $workspace->agents()->whereKey($duplicateAgentIds)->delete(); + WorkspaceChatParticipant::query() + ->whereIn('workspace_agent_id', $duplicateAgentIds) + ->get() + ->each(function (WorkspaceChatParticipant $participant) use ($workspaceAgent): void { + $participant->forceFill([ + 'workspace_agent_id' => $workspaceAgent->getKey(), + 'display_name' => $workspaceAgent->name, + ])->save(); + }); + + $duplicateAgents + ->each(fn (WorkspaceAgent $duplicateAgent): bool => $duplicateAgent->delete()); } continue; diff --git a/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php b/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php index 3c41708..7643748 100644 --- a/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php +++ b/database/migrations/2026_03_27_174741_add_has_agent_participant_to_workspace_chats_table.php @@ -27,9 +27,9 @@ public function up(): void ->map(fn (mixed $chatId): int => (int) $chatId) ->all(); - foreach ($chatIds as $chatId) { + if ($chatIds !== []) { DB::table('workspace_chats') - ->where('id', $chatId) + ->whereIn('id', $chatIds) ->update(['has_agent_participant' => true]); } }); diff --git a/tests/Feature/SurrealWorkspaceModelTest.php b/tests/Feature/SurrealWorkspaceModelTest.php index e0dcc90..4879eb9 100644 --- a/tests/Feature/SurrealWorkspaceModelTest.php +++ b/tests/Feature/SurrealWorkspaceModelTest.php @@ -3,6 +3,8 @@ use App\Models\SurrealWorkspace; use App\Models\Workspace; use App\Models\WorkspaceAgent; +use App\Models\WorkspaceChat; +use App\Models\WorkspaceChatParticipant; use App\Services\Surreal\SurrealCliClient; use App\Services\Surreal\SurrealConnection; use App\Services\Surreal\SurrealDocumentStore; @@ -340,6 +342,156 @@ } }); +test('workspace agent repair preserves chat participants when duplicate defaults exist on surreal', function () { + $client = app(SurrealCliClient::class); + + if (! $client->isAvailable()) { + $this->markTestSkipped('The `surreal` CLI is not available in this environment.'); + } + + $storagePath = storage_path('app/surrealdb/workspace-agent-duplicate-repair-test-'.Str::uuid()); + $originalDefaultConnection = config('database.default'); + + File::deleteDirectory($storagePath); + File::ensureDirectoryExists(dirname($storagePath)); + + try { + $server = retryStartingWorkspaceServer($client, $storagePath); + $port = $server['port']; + $endpoint = $server['endpoint']; + + config()->set('database.default', 'surreal'); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', $port); + config()->set('surreal.endpoint', $endpoint); + config()->set('surreal.username', 'root'); + config()->set('surreal.password', 'root'); + config()->set('surreal.namespace', 'katra'); + config()->set('surreal.database', 'workspace_agent_duplicate_repair_test'); + config()->set('surreal.storage_engine', 'surrealkv'); + config()->set('surreal.storage_path', $storagePath); + config()->set('surreal.runtime', 'local'); + config()->set('surreal.autostart', false); + + DB::purge('surreal'); + + $schema = Schema::connection('surreal'); + + $schema->create('connection_workspaces', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('instance_connection_id'); + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + $schema->create('workspace_agents', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id'); + $table->string('agent_key'); + $table->string('name'); + $table->string('agent_class'); + $table->text('summary')->nullable(); + $table->timestamps(); + }); + + $schema->create('workspace_chats', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('workspace_id'); + $table->string('name'); + $table->string('slug'); + $table->string('kind'); + $table->string('visibility'); + $table->text('summary')->nullable(); + $table->boolean('has_agent_participant')->default(false); + $table->timestamps(); + }); + + $schema->create('workspace_chat_participants', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('chat_id'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('workspace_agent_id')->nullable(); + $table->string('participant_type'); + $table->string('participant_key'); + $table->string('display_name'); + $table->timestamps(); + }); + + $workspace = Workspace::create([ + 'instance_connection_id' => 42, + 'name' => 'Atlas', + 'slug' => 'atlas', + 'summary' => 'Workspace ready for duplicate agent repair testing.', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $survivingAgent = WorkspaceAgent::create([ + 'workspace_id' => $workspace->getKey(), + 'agent_key' => WorkspaceAgent::KEY_WORKSPACE_GUIDE, + 'name' => 'Workspace Guide', + 'agent_class' => WorkspaceAgent::CLASS_WORKSPACE_GUIDE, + 'summary' => 'Outdated summary', + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + + $duplicateAgent = WorkspaceAgent::create([ + 'workspace_id' => $workspace->getKey(), + 'agent_key' => WorkspaceAgent::KEY_WORKSPACE_GUIDE, + 'name' => 'Workspace Guide Copy', + 'agent_class' => WorkspaceAgent::CLASS_WORKSPACE_GUIDE, + 'summary' => 'Duplicate summary', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $chat = WorkspaceChat::create([ + 'workspace_id' => $workspace->getKey(), + 'name' => 'Workspace Guide', + 'slug' => 'workspace-guide', + 'kind' => WorkspaceChat::KIND_DIRECT, + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + 'summary' => 'Duplicate agent preservation test.', + 'has_agent_participant' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + WorkspaceChatParticipant::create([ + 'chat_id' => $chat->getKey(), + 'workspace_agent_id' => $duplicateAgent->getKey(), + 'participant_type' => WorkspaceChatParticipant::TYPE_AGENT, + 'participant_key' => 'agent:'.WorkspaceAgent::KEY_WORKSPACE_GUIDE, + 'display_name' => 'Workspace Guide Copy', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $agents = app(WorkspaceAgentManager::class)->ensureDefaults($workspace->fresh()); + $participant = WorkspaceChatParticipant::query()->first(); + + expect($agents)->toHaveCount(1) + ->and($agents->first()?->getKey())->toBe($survivingAgent->getKey()) + ->and($agents->first()?->summary)->toBe('Helps shape durable, graph-native collaboration inside this workspace.') + ->and($participant)->not()->toBeNull() + ->and($participant?->workspace_agent_id)->toBe($survivingAgent->getKey()) + ->and($participant?->display_name)->toBe('Workspace Guide'); + } finally { + config()->set('database.default', $originalDefaultConnection); + + DB::purge('surreal'); + + if (isset($server['process'])) { + $server['process']->stop(1); + } + + File::deleteDirectory($storagePath); + } +}); + /** * @return array{endpoint: string, port: int, process: Process} */