From f221298de46c52e26ae9a00e212f7537e98684c4 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 19:07:42 +0200 Subject: [PATCH 1/9] feat(agent): add agent user management, allow @mentions to agents, responses in 1-1 rooms, reactions, agency confirmations Signed-off-by: Julien Veyssier --- appinfo/routes.php | 5 + lib/AppInfo/Application.php | 7 + lib/Controller/AgentsApiController.php | 189 +++ lib/Db/Agent.php | 58 + lib/Db/AgentMapper.php | 104 ++ lib/Db/ChattyLLM/Session.php | 62 +- lib/Db/ChattyLLM/SessionMapper.php | 56 + lib/Listener/ChattyLLMTaskListener.php | 28 +- lib/Listener/Talk/AgentMentionListener.php | 46 + lib/Listener/Talk/AgentReactionListener.php | 68 + .../Version040000Date20261023083738.php | 77 ++ .../Version040000Date20261023120000.php | 51 + lib/Service/AgentTalkService.php | 659 ++++++++++ lib/Service/AgentUserException.php | 13 + lib/Service/AgentUserService.php | 53 + lib/Service/AgentsService.php | 141 +++ lib/Service/ChatService.php | 21 +- lib/Settings/Admin.php | 22 + openapi.json | 1093 ++++++++++++++++- src/components/AdminSettings.vue | 24 + src/components/TalkAgentUsers.vue | 343 ++++++ .../Listener/ChattyLLMTaskListenerTest.php | 2 + tests/unit/Service/AgentUserServiceTest.php | 58 + tests/unit/Service/AgentsServiceTest.php | 127 ++ 24 files changed, 3295 insertions(+), 12 deletions(-) create mode 100644 lib/Controller/AgentsApiController.php create mode 100644 lib/Db/Agent.php create mode 100644 lib/Db/AgentMapper.php create mode 100644 lib/Listener/Talk/AgentMentionListener.php create mode 100644 lib/Listener/Talk/AgentReactionListener.php create mode 100644 lib/Migration/Version040000Date20261023083738.php create mode 100644 lib/Migration/Version040000Date20261023120000.php create mode 100644 lib/Service/AgentTalkService.php create mode 100644 lib/Service/AgentUserException.php create mode 100644 lib/Service/AgentUserService.php create mode 100644 lib/Service/AgentsService.php create mode 100644 src/components/TalkAgentUsers.vue create mode 100644 tests/unit/Service/AgentUserServiceTest.php create mode 100644 tests/unit/Service/AgentsServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index e49b7d1d4..0b27a5d31 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -43,6 +43,11 @@ ['name' => 'agentSkillsApi#storeSkill', 'url' => '/api/{apiVersion}/skills', 'verb' => 'POST', 'requirements' => $requirements], ['name' => 'agentSkillsApi#loadSkill', 'url' => '/api/{apiVersion}/skills/{skillName}', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'agentsApi#listAgents', 'url' => '/api/{apiVersion}/agents', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'agentsApi#createAgent', 'url' => '/api/{apiVersion}/agents', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'agentsApi#updateAgent', 'url' => '/api/{apiVersion}/agents/{id}', 'verb' => 'PUT', 'requirements' => $requirements], + ['name' => 'agentsApi#deleteAgent', 'url' => '/api/{apiVersion}/agents/{id}', 'verb' => 'DELETE', 'requirements' => $requirements], + ['name' => 'chattyLLM#newSession', 'url' => '/chat/sessions', 'verb' => 'POST', 'postfix' => 'restful'], ['name' => 'chattyLLM#updateChatSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'PUT', 'postfix' => 'restful'], ['name' => 'chattyLLM#deleteSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'DELETE', 'postfix' => 'restful'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 228b846b8..32a9ed9f4 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,6 +18,8 @@ use OCA\Assistant\Listener\NewFileMenuTaskFailedListener; use OCA\Assistant\Listener\NewFileMenuTaskSuccessfulListener; use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener; +use OCA\Assistant\Listener\Talk\AgentMentionListener; +use OCA\Assistant\Listener\Talk\AgentReactionListener; use OCA\Assistant\Listener\TaskFailedListener; use OCA\Assistant\Listener\TaskOutputFileReferenceListener; use OCA\Assistant\Listener\TaskSuccessfulListener; @@ -108,6 +110,11 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + // the Talk event classes are not necessarily autoloadable when this app + // file is registered, the listeners themselves guard for Talk being absent + $context->registerEventListener(\OCA\Talk\Events\ChatMessageSentEvent::class, AgentMentionListener::class); + $context->registerEventListener(\OCA\Talk\Events\ReactionAddedEvent::class, AgentReactionListener::class); + if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) { $context->registerTaskProcessingProvider(AudioToAudioChatProvider::class); } diff --git a/lib/Controller/AgentsApiController.php b/lib/Controller/AgentsApiController.php new file mode 100644 index 000000000..3176e44c7 --- /dev/null +++ b/lib/Controller/AgentsApiController.php @@ -0,0 +1,189 @@ + + */ + private function serializeAgents(): array { + $agents = $this->agentsService->getAllAgents(); + $result = []; + foreach ($agents as $agent) { + $result[] = $this->serializeAgent($agent); + } + return $result; + } + + private function serializeAgent(Agent $agent): array { + $user = $this->userManager->get($agent->getUserId()); + return [ + 'id' => (int)$agent->getId(), + 'user_id' => $agent->getUserId(), + 'display_name' => $user?->getDisplayName() ?: $agent->getUserId(), + 'personality' => $agent->getPersonality(), + 'scope' => $agent->getScope(), + ]; + } + + private function checkAdmin(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + return $this->groupManager->isInGroup($user->getUID(), 'admin'); + } + + /** + * List all agents (admin only) + * + * @return DataResponse}, array{}>|DataResponse + * + * 200: Agents returned + * 403: User is not an admin + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function listAgents(): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + return new DataResponse(['agents' => $this->serializeAgents()]); + } + + /** + * Create an agent (admin only) + * + * A dedicated Nextcloud user is created in the default database backend if it does not exist yet. + * + * @param string $name The name of the agent + * @param string $userId The user id of the agent's dedicated Nextcloud user + * @param string $personality The agent's system prompt + * @param string $scope Optional scope for later use + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 201: Agent created + * 400: Invalid data + * 403: User is not an admin + * 500: Agent creation failed + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function createAgent(string $name = '', string $userId = '', string $personality = '', string $scope = ''): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + $name = trim($name); + $userId = trim($userId); + if ($name === '' || $userId === '') { + return new DataResponse(['error' => $this->l10n->t('Name and user id are required')], Http::STATUS_BAD_REQUEST); + } + if (strlen($personality) > 64_000) { + return new DataResponse(['error' => $this->l10n->t('The agent personality is too long')], Http::STATUS_BAD_REQUEST); + } + try { + $agent = $this->agentsService->createAgent($name, $userId, $personality, $scope); + return new DataResponse(['agent' => $this->serializeAgent($agent)], Http::STATUS_CREATED); + } catch (AgentUserException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Exception|Throwable $e) { + return new DataResponse(['error' => $this->l10n->t('Failed to create the agent')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update an agent (admin only) + * + * @param int $id The agent id + * @param string|null $personality The new personality (system prompt) + * @param string|null $scope The new scope + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 200: Agent updated + * 400: Invalid data + * 403: User is not an admin + * 404: Agent not found + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + $agent = $this->agentsService->getAgentById($id); + if ($agent === null) { + return new DataResponse(['error' => $this->l10n->t('Agent not found')], Http::STATUS_NOT_FOUND); + } + try { + $agent = $this->agentsService->updateAgent($id, $personality, $scope); + return new DataResponse(['agent' => $this->serializeAgent($agent)]); + } catch (AgentUserException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Exception|Throwable $e) { + return new DataResponse(['error' => $this->l10n->t('Failed to update the agent')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete an agent (admin only) + * + * @param int $id The agent id + * @param int $deleteUser Whether the related Nextcloud user should be deleted too + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 200: Agent deleted + * 403: User is not an admin + * 404: Agent not found + * 500: Agent deletion failed + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function deleteAgent(int $id, int $deleteUser = 0): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + $agent = $this->agentsService->getAgentById($id); + if ($agent === null) { + return new DataResponse(['error' => $this->l10n->t('Agent not found')], Http::STATUS_NOT_FOUND); + } + try { + $this->agentsService->deleteAgent($id, $deleteUser === 1); + return new DataResponse(['action' => 'deleted']); + } catch (\Exception|Throwable $e) { + return new DataResponse(['error' => $this->l10n->t('Failed to delete the agent')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lib/Db/Agent.php b/lib/Db/Agent.php new file mode 100644 index 000000000..d8fd83e57 --- /dev/null +++ b/lib/Db/Agent.php @@ -0,0 +1,58 @@ +addType('userId', Types::STRING); + $this->addType('personality', Types::TEXT); + $this->addType('scope', Types::TEXT); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'id' => $this->getId(), + 'user_id' => $this->getUserId(), + 'personality' => $this->getPersonality(), + 'scope' => $this->getScope(), + ]; + } +} diff --git a/lib/Db/AgentMapper.php b/lib/Db/AgentMapper.php new file mode 100644 index 000000000..4fd8acc18 --- /dev/null +++ b/lib/Db/AgentMapper.php @@ -0,0 +1,104 @@ + + */ +class AgentMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'assistant_agents', Agent::class); + } + + /** + * @param int $id + * @return Agent + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getById(int $id): Agent { + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + /** + * @return list + * @throws Exception + */ + public function getAllAgents(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->orderBy('id', 'ASC'); + return $this->findEntities($qb); + } + + /** + * @param string $userId + * @return Agent + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getByUserId(string $userId): Agent { + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + return $this->findEntity($qb); + } + + /** + * Whether a list of user ids contains registered agents, keyed by user id + * + * @param list $userIds + * @return array + * @throws Exception + */ + public function getByUserIds(array $userIds): array { + if ($userIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->in('user_id', $qb->createParameter('userIds'))) + ->setParameter('userIds', $userIds, IQueryBuilder::PARAM_STR_ARRAY); + $result = $qb->executeQuery(); + $agents = []; + while (($row = $result->fetchAssociative()) !== false) { + $agent = $this->mapRowToEntity($row); + $agents[$agent->getUserId()] = $agent; + } + $result->closeCursor(); + return $agents; + } + + /** + * @param Agent $agent + * @return Agent + * @throws Exception + */ + public function deleteAgent(Agent $agent): Agent { + $this->delete($agent); + return $agent; + } +} diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index 1541aaac3..5e50fa6e5 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -27,6 +27,16 @@ * @method \void setAgencyPendingActions(?string $agencyPendingActions) * @method \void setAssignmentId(?int $id) * @method \int|null getAssignmentId() + * @method \string|null getAgentUserId() + * @method \void setAgentUserId(?string $agentUserId) + * @method \string|null getRoomToken() + * @method \void setRoomToken(?string $roomToken) + * @method \int|null getThreadId() + * @method \void setThreadId(?int $threadId) + * @method \string|null getAgentSeenRoomToken() + * @method \void setAgentSeenRoomToken(?string $agentSeenRoomToken) + * @method \int|null getAgentSeenCommentId() + * @method \void setAgentSeenCommentId(?int $agentSeenCommentId) */ class Session extends Entity implements \JsonSerializable { /** @var string */ @@ -63,6 +73,34 @@ class Session extends Entity implements \JsonSerializable { */ protected $assignmentId; + /** + * When the session is used by an agent through Talk: the agent's NC user id + * @var ?string + */ + protected $agentUserId; + + /** + * When the session is used by an agent through Talk: the token of the 1-1 Talk room + * @var ?string + */ + protected $roomToken; + + /** + * When the session is used by an agent through Talk: the id of the Talk thread for this session + * @var ?int + */ + protected $threadId; + + /** + * The Talk room + comment of the last 👀 reaction of the agent on a human message, + * replaced by ✅ or 🔴 once the requested message generation completed/failed + * @var ?string + */ + protected $agentSeenRoomToken; + + /** @var ?int */ + protected $agentSeenCommentId; + public static $columns = [ 'id', 'user_id', @@ -73,7 +111,12 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'is_summary_up_to_date', 'is_remembered', - 'assignment_id' + 'assignment_id', + 'agent_user_id', + 'room_token', + 'thread_id', + 'agent_seen_room_token', + 'agent_seen_comment_id', ]; public static $fields = [ 'id', @@ -85,7 +128,12 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'isSummaryUpToDate', 'isRemembered', - 'assignmentId' + 'assignmentId', + 'agentUserId', + 'roomToken', + 'threadId', + 'agentSeenRoomToken', + 'agentSeenCommentId', ]; public function __construct() { @@ -98,6 +146,11 @@ public function __construct() { $this->addType('isSummaryUpToDate', Types::SMALLINT); $this->addType('isRemembered', Types::SMALLINT); $this->addType('assignmentId', Types::BIGINT); + $this->addType('agentUserId', Types::STRING); + $this->addType('roomToken', Types::STRING); + $this->addType('threadId', Types::BIGINT); + $this->addType('agentSeenRoomToken', Types::STRING); + $this->addType('agentSeenCommentId', Types::BIGINT); } #[\ReturnTypeWillChange] @@ -113,6 +166,11 @@ public function jsonSerialize() { 'is_summary_up_to_date' => $this->getIsSummaryUpToDate(), 'is_remembered' => $this->getIsRemembered(), 'assignment_id' => $this->getAssignmentId(), + 'agent_user_id' => $this->getAgentUserId(), + 'room_token' => $this->getRoomToken(), + 'thread_id' => $this->getThreadId(), + 'agent_seen_room_token' => $this->getAgentSeenRoomToken(), + 'agent_seen_comment_id' => $this->getAgentSeenCommentId(), ]; } diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 5c089c9bd..436cea20e 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -82,6 +82,62 @@ public function getUserSessionForAssignment(string $userId, int $assignmentId): return $this->findEntity($qb); } + /** + * @param string $userId + * @param string $agentUserId + * @param string $roomToken + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getAgentSession(string $userId, string $agentUserId, string $roomToken): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('agent_user_id', $qb->createPositionalParameter($agentUserId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('room_token', $qb->createPositionalParameter($roomToken, IQueryBuilder::PARAM_STR))) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + return $this->findEntity($qb); + } + + /** + * @param string $userId + * @param int $threadId The Talk thread id (same as the id of the thread starter Talk comment) + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getAgentSessionByThread(string $userId, int $threadId): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('thread_id', $qb->createPositionalParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNotNull('agent_user_id')) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + return $this->findEntity($qb); + } + + /** + * @param int $sessionId + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getSessionById(int $sessionId): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + /** * @return \Generator * @throws \OCP\DB\Exception diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index c1dcc0580..969592313 100644 --- a/lib/Listener/ChattyLLMTaskListener.php +++ b/lib/Listener/ChattyLLMTaskListener.php @@ -13,6 +13,7 @@ use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; use OCA\Assistant\Db\ChattyLLM\SessionMapper; +use OCA\Assistant\Service\AgentTalkService; use OCA\Assistant\Service\NotificationService; use OCA\Assistant\Service\TaskProcessingService; use OCP\AppFramework\Db\DoesNotExistException; @@ -32,6 +33,7 @@ public function __construct( private MessageMapper $messageMapper, private SessionMapper $sessionMapper, private TaskProcessingService $taskProcessingService, + private AgentTalkService $agentTalkService, private LoggerInterface $logger, private NotificationService $notificationService, ) { @@ -43,13 +45,27 @@ public function handle(Event $event): void { $customId = $task->getCustomId(); if (preg_match('/^chatty-llm:(\d+)/', $customId, $matches)) { $sessionId = (int)$matches[1]; - $session = $this->sessionMapper->getUserSession($task->getUserId(), $sessionId); + try { + $session = $this->sessionMapper->getUserSession($task->getUserId(), $sessionId); + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + return; + } catch (\OCP\AppFramework\Db\MultipleObjectsReturnedException|\OCP\DB\Exception $e) { + $this->logger->error('Could not load the session of a failed chat task', ['exception' => $e]); + return; + } $assignmentId = $session->getAssignmentId(); if ($assignmentId !== null) { $this->notificationService->sendAssignmentNotification( $task->getUserId(), $task, $session ); } + // in the agent-as-a-user feature, the 👀 reaction of the last message + // becomes 🔴 when the generation failed + try { + $this->agentTalkService->markGenerationFailed($task->getUserId(), $sessionId); + } catch (\Throwable $e) { + $this->logger->error('Failed to mark the agent message as failed in Talk', ['exception' => $e]); + } } return; } @@ -180,6 +196,16 @@ public function handle(Event $event): void { } $this->sessionMapper->update($session); + + // in the agent-as-a-user feature, the generated message is posted + // in the 1-1 Talk conversation with the human user + if ($session->getAgentUserId() !== null) { + $this->agentTalkService->postAgentTalkReply( + $session, + $message->getContent(), + $taskOutput['actions'] ?? null, + ); + } } } diff --git a/lib/Listener/Talk/AgentMentionListener.php b/lib/Listener/Talk/AgentMentionListener.php new file mode 100644 index 000000000..ff38fca0f --- /dev/null +++ b/lib/Listener/Talk/AgentMentionListener.php @@ -0,0 +1,46 @@ + + * + * Handles messages sent by users in Talk conversations with agents: + * the agent answer is generated in a classic Assistant chat session + * and posts back in the 1-1 Talk conversation with the human user. + */ +class AgentMentionListener implements IEventListener { + + public function __construct( + private AgentTalkService $agentTalkService, + private LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if (!class_exists('OCA\Talk\Events\ChatMessageSentEvent') + || !$event instanceof \OCA\Talk\Events\ChatMessageSentEvent) { + return; + } + try { + $this->agentTalkService->handleIncomingTalkMessage($event->getRoom(), $event->getComment()); + } catch (\Throwable $e) { + $this->logger->error('Failed to react to a Talk chat message for an agent', ['exception' => $e]); + } + } +} diff --git a/lib/Listener/Talk/AgentReactionListener.php b/lib/Listener/Talk/AgentReactionListener.php new file mode 100644 index 000000000..36c432a95 --- /dev/null +++ b/lib/Listener/Talk/AgentReactionListener.php @@ -0,0 +1,68 @@ + + * + * Handles the reactions of human users on confirmation-related agent Talk messages: + * they are sent back to the agent context as approve/reject signals. + */ +class AgentReactionListener implements IEventListener { + + public function __construct( + private AgentTalkService $agentTalkService, + private \OCA\Assistant\Service\AgentsService $agentsService, + private LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if (!class_exists('OCA\Talk\Events\ReactionAddedEvent') + || !$event instanceof \OCA\Talk\Events\ReactionAddedEvent) { + return; + } + try { + $reaction = $event->getReaction(); + if ($event->getActorType() !== 'users') { + return; + } + // ignore reactions added by agent users themselves + if ($this->agentsService->getAgentByUserId($event->getActorId()) !== null) { + return; + } + $comment = $event->getMessage(); + $referenceId = $comment->getReferenceId(); + if (!preg_match('/^' . AgentTalkService::CONFIRMATION_REFERENCE_PREFIX . '(\d+)(:[0-9]+)?$/', $referenceId ?? '', $matches)) { + return; + } + if (!in_array($reaction, [AgentTalkService::REACTION_APPROVE, AgentTalkService::REACTION_REJECT], true)) { + return; + } + $this->agentTalkService->handleConfirmationReaction( + $event->getActorId(), + (int)$matches[1], + (int)$comment->getId(), + $reaction, + $event->getRoom(), + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to react to a Talk reaction for an agent', ['exception' => $e]); + } + } +} diff --git a/lib/Migration/Version040000Date20261023083738.php b/lib/Migration/Version040000Date20261023083738.php new file mode 100644 index 000000000..e32b098cf --- /dev/null +++ b/lib/Migration/Version040000Date20261023083738.php @@ -0,0 +1,77 @@ +hasTable('assistant_agents')) { + $table = $schema->createTable('assistant_agents'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('personality', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('scope', Types::TEXT, [ + 'notnull' => false, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id'], 'assistant_agents_user_id'); + $schemaChanged = true; + } + + if ($schema->hasTable('assistant_chat_sns')) { + $table = $schema->getTable('assistant_chat_sns'); + if (!$table->hasColumn('agent_user_id')) { + $table->addColumn('agent_user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('room_token', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('thread_id', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addIndex(['user_id', 'agent_user_id', 'room_token'], 'assistant_chat_agent_idx'); + $schemaChanged = true; + } + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/Migration/Version040000Date20261023120000.php b/lib/Migration/Version040000Date20261023120000.php new file mode 100644 index 000000000..a9c8ea6a8 --- /dev/null +++ b/lib/Migration/Version040000Date20261023120000.php @@ -0,0 +1,51 @@ +hasTable('assistant_chat_sns')) { + $table = $schema->getTable('assistant_chat_sns'); + // the Talk room + message of the last 👀 reaction of the agent, + // so it can be replaced by the generated answer state (✅/🔴) + if (!$table->hasColumn('agent_seen_room_token')) { + $table->addColumn('agent_seen_room_token', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('agent_seen_comment_id', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + 'unsigned' => true, + ]); + $schemaChanged = true; + } + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php new file mode 100644 index 000000000..655e814a1 --- /dev/null +++ b/lib/Service/AgentTalkService.php @@ -0,0 +1,659 @@ +getActorType(); + $authorActorId = $comment->getActorId(); + if ($authorActorType !== 'users') { + return; + } + // Ignore messages sent by agent users to avoid loops + if ($this->agentsService->getAgentByUserId($authorActorId) !== null) { + return; + } + + $agent = $this->getAgentTarget($room, $comment, $authorActorId); + if ($agent === null) { + return; + } + + $humanUser = $this->userManager->get($authorActorId); + if ($humanUser === null) { + return; + } + + $this->processAgentInteraction( + $room, + $agent, + $humanUser, + $comment, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to handle a Talk message for an agent', ['exception' => $e]); + } + } + + /** + * Find the agent user targeted by a Talk message: + * - in a 1-1 conversation: the other attendee, if it is a registered agent + * - in a group room: a mentioned registered agent that is a participant of the room. + * The agent must always answer in the private 1-1 conversation + * with the user who sent the message. + */ + private function getAgentTarget( + \OCA\Talk\Room $room, + IComment $comment, + string $authorActorId, + ): ?Agent { + if ($room->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) { + $partnerUserId = $this->getOneToOnePartnerUserId($room, $authorActorId); + if ($partnerUserId !== null) { + return $this->agentsService->getAgentByUserId($partnerUserId); + } + return null; + } + + // In a group room the mentioned agent must already be a participant of the room + /** @var \OCA\Talk\Manager $talkManager */ + $talkManager = Server::get(\OCA\Talk\Manager::class); + $mentions = $comment->getMentions(); + foreach ($mentions as $mention) { + if ($mention['type'] !== 'user') { + continue; + } + $mentionId = $mention['id']; + if ($this->agentsService->getAgentByUserId($mentionId) !== null + && $talkManager->isUserAttendeeInRoom($mentionId, $room->getToken())) { + return $this->agentsService->getAgentByUserId($mentionId); + } + } + return null; + } + + /** + * @return string|null The user id of the other actor in the room + */ + private function getOneToOnePartnerUserId(\OCA\Talk\Room $room, string $authorUserId): ?string { + try { + return $room->getSecondParticipant($authorUserId); + } catch (\InvalidArgumentException|JsonException $e) { + return null; + } + } + + /** + * @throws Exception + * @throws InternalException + * @throws UnauthorizedException + * @throws NotFoundException + * @throws BadRequestException + */ + private function processAgentInteraction( + \OCA\Talk\Room $room, + Agent $agent, + IUser $humanUser, + IComment $comment, + ): void { + $humanUserId = $humanUser->getUID(); + + // when the message is a reply inside the Talk thread of an agent session, + // the generation happens in that session and the answer goes back to the same thread + if ($room->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) { + $triggerThreadId = (int)($comment->getTopmostParentId() ?? 0); + if ($triggerThreadId !== 0) { + try { + $threadSession = $this->sessionMapper->getAgentSessionByThread($humanUserId, $triggerThreadId); + $this->storeAndSchedule($threadSession, $comment, $humanUserId, $room, $agent); + return; + } catch (DoesNotExistException) { + // the reply is not in the thread of an agent session: handle it below + } + } + } + + // the 1-1 room the agent answers in + $this->getOrCreateOneToOneRoom($humanUser, $agent->getUserId()); + + // the name of the context is the origin room of the mention/message, + // or the human display name when it was a direct 1-1 conversation + $contextRoomName = ($room->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) + ? $humanUser->getDisplayName() + : $room->getName(); + + // find or create the assistant chat session dedicated to this agent/origin-room context + $session = $this->getOrCreateAgentSession($humanUserId, $agent, $contextRoomName, $room->getToken()); + + $this->storeAndSchedule($session, $comment, $humanUserId, $room, $agent); + } + + /** + * @throws Exception + * @throws InternalException + * @throws UnauthorizedException + * @throws NotFoundException + * @throws BadRequestException + */ + private function storeAndSchedule(Session $session, IComment $comment, string $humanUserId, \OCA\Talk\Room $room, Agent $agent): void { + // store the human Talk message in the chat session + $creationTimestamp = $comment->getCreationDateTime()->getTimestamp(); + $talkContent = (string)$comment->getMessage(); + $this->chatService->createMessage( + $humanUserId, + (int)$session->getId(), + Message::ROLE_HUMAN, + $talkContent, + $creationTimestamp, + null, + ); + + // mark the message as seen before scheduling, so that the reaction + // also happens when the generation cannot be scheduled right away + // (the session answers one message at a time) + $this->addAgentReaction($room, $agent->getUserId(), (int)$comment->getId(), self::REACTION_READ); + // remember the seen message to later replace 👀 by ✅/🔴 after the generation + $session->setAgentSeenRoomToken($room->getToken()); + $session->setAgentSeenCommentId((int)$comment->getId()); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to store the seen message of the agent session', ['exception' => $e]); + } + + // schedule generation of the agent answer + $this->chatService->scheduleMessageGeneration($humanUserId, (int)$session->getId()); + } + + /** + * The session is dedicated to one agent + human user + origin Talk room context: + * the Talk room in which the human mentioned the agent or wrote to him directly. + * All the agent answers are posted as one Talk thread of the 1-1 room + * between the human and the agent. + * + * @throws Exception + */ + private function getOrCreateAgentSession(string $humanUserId, Agent $agent, string $contextRoomName, string $originRoomToken): Session { + try { + return $this->sessionMapper->getAgentSession( + $humanUserId, $agent->getUserId(), $originRoomToken + ); + } catch (DoesNotExistException) { + // continue with session creation + } catch (Exception $e) { + throw new InternalException($e->getMessage(), previous: $e); + } + + $agentName = $this->getAgentDisplayName($agent); + $sessionTitle = $agentName . self::THREAD_TITLE_SEPARATOR . $contextRoomName; + + $session = $this->chatService->createChatSession( + $humanUserId, + timestamp: null, + title: $sessionTitle, + systemPrompt: $agent->getPersonality(), + ); + $session->setAgentUserId($agent->getUserId()); + $session->setRoomToken($originRoomToken); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + throw new InternalException('Failed to store the agent context on the chat session', previous: $e); + } + return $session; + } + + public function getAgentDisplayName(Agent $agent): string { + $userId = $agent->getUserId(); + $user = $this->userManager->get($userId); + $displayName = null; + if ($user !== null) { + $displayName = $user->getDisplayName(); + } + return $displayName ?: $userId; + } + + /** + * The 1-1 Talk room between a human user and an agent user + * + * @throws AgentUserException + */ + private function getOrCreateOneToOneRoom(IUser $humanUser, string $agentUserId): \OCA\Talk\Room { + /** @var \OCA\Talk\Manager $talkManager */ + $talkManager = Server::get(\OCA\Talk\Manager::class); + try { + return $talkManager->getOne2OneRoom($humanUser->getUID(), $agentUserId); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException) { + // fall through to the creation below + } + + $agentUser = $this->userManager->get($agentUserId); + if ($agentUser === null) { + throw new AgentUserException('The agent user ' . $agentUserId . ' does not exist'); + } + + /** @var \OCA\Talk\Service\RoomService $talkRoomService */ + $talkRoomService = Server::get(\OCA\Talk\Service\RoomService::class); + try { + return $talkRoomService->createOneToOneConversation($humanUser, $agentUser); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException|\InvalidArgumentException $e) { + throw new AgentUserException('Failed to create the 1-1 conversation with the agent user ' . $agentUserId, previous: $e); + } + } + + /** + * Called by ChattyLLMTaskListener instead of posting the agent answer + * in Talk: it will post the answer in the 1-1 Talk conversation thread. + * + * @param Session $session + * @param string $content The assistant message content + * @param string|null $pendingActions The pending sensitive actions (confirmation request) if any + * @return void + */ + public function postAgentTalkReply( + Session $session, + string $content, + ?string $pendingActions, + ): void { + if ($session->getAgentUserId() === null) { + return; + } + // only for agent sessions + if (!class_exists('OCA\Talk\Chat\ChatManager')) { + return; + } + $agentUser = $this->userManager->get($session->getAgentUserId()); + if ($agentUser === null) { + $this->logger->warning('The agent user was deleted while an agent answer was being handled'); + return; + } + $humanUser = $this->userManager->get($session->getUserId()); + if ($humanUser === null) { + $this->logger->warning('The session owner user was deleted while an agent answer was being handled'); + return; + } + try { + $room = $this->getOrCreateOneToOneRoom($humanUser, $agentUser->getUID()); + } catch (AgentUserException|\OCA\Talk\Exceptions\RoomNotFoundException $e) { + $this->logger->warning('Could not get the 1-1 Talk room to post an agent reply', [ + 'agentuserId' => $agentUser->getUID(), + 'humanUserId' => $humanUser->getUID(), + 'exception' => $e, + ]); + return; + } + + // messages sent outside a thread of a 1-1 conversation are answered + // with plain 1-1 messages; answers to a thread conversation go back in the thread + $threaded = true; + if ($session->getRoomToken() !== null) { + try { + $originRoom = $this->getRoomByToken($session->getRoomToken()); + $threaded = $originRoom->getType() !== \OCA\Talk\Room::TYPE_ONE_TO_ONE; + } catch (\OCA\Talk\Exceptions\RoomNotFoundException) { + // the origin room may have been deleted, still answer threaded + } + } + + $posted = $this->postAgentMessage( + $room, + $session, + $content, + referenceId: null, + threaded: $threaded, + ); + + $confirmPosted = false; + if ($pendingActions !== null && trim($pendingActions) !== '') { + $confirmPosted = $this->postAgentMessage( + $room, + $session, + $pendingActions, + referenceId: self::CONFIRMATION_REFERENCE_PREFIX . $session->getId() . ':' . time(), + addApproveRejectReactions: true, + threaded: $threaded, + ); + } + + // an agency confirmation turn has no reply content, only the confirmation message + $this->markGenerationCompleted($session, $posted || $confirmPosted); + } + + /** + * Called by a listener when a human user reacts in Talk to a confirmation message + * (sensitive actions of the agency feature). + * + * The agent removes its own 👍/👎 (only the user's reaction remains), + * marks the message with 👀 as seen, and does the same as when confirming + * or rejecting from the assistant UI. + * + * @param string $humanUserId + * @param int $sessionId + * @param int $messageId The id of the Talk message that was reacted to + * @param string $reaction + * @return void + */ + public function handleConfirmationReaction(string $humanUserId, int $sessionId, int $messageId, string $reaction, \OCA\Talk\Room $room): void { + if (!in_array($reaction, [self::REACTION_APPROVE, self::REACTION_REJECT], true)) { + return; + } + try { + $session = $this->sessionMapper->getUserSession($humanUserId, $sessionId); + } catch (DoesNotExistException|Exception $e) { + $this->logger->warning('Could not find the session matching the Talk reaction', [ + 'humanUserId' => $humanUserId, + 'sessionId' => $sessionId, + ]); + return; + } + if ($session->getAgentUserId() === null) { + return; + } + + // remove the agent's own thumbs so only the one of the user remains, + // then mark the confirmation as being processed + $this->deleteAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_APPROVE); + $this->deleteAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_REJECT); + $this->addAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_READ); + // remember the seen message to later replace 👀 by ✅/🔴 after the confirmation run + $session->setAgentSeenRoomToken($room->getToken()); + $session->setAgentSeenCommentId($messageId); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to store the seen confirmation of the agent session', ['exception' => $e]); + } + try { + $this->chatService->scheduleMessageGeneration( + $humanUserId, + $sessionId, + $reaction === self::REACTION_APPROVE ? 1 : 0, + ); + } catch (InternalException|NotFoundException|BadRequestException|UnauthorizedException $e) { + $this->logger->error('Failed to schedule the confirmation follow-up after a Talk reaction', ['exception' => $e]); + } + } + + private function postAgentMessage( + \OCA\Talk\Room $room, + Session $session, + string $content, + ?string $referenceId = null, + bool $addApproveRejectReactions = false, + bool $threaded = true, + ): bool { + if (trim($content) === '') { + return false; + } + $agentUser = $this->userManager->get($session->getAgentUserId()); + if ($agentUser === null) { + return false; + } + /** @var \OCA\Talk\Chat\ChatManager $chatManager */ + $chatManager = Server::get(\OCA\Talk\Chat\ChatManager::class); + + $threadId = \OCA\Talk\Model\Thread::THREAD_NONE; + $threadTitle = ''; + if ($threaded) { + if ($session->getThreadId() !== null) { + // answer in the existing thread of the session + $threadId = $session->getThreadId(); + } else { + // create the thread of the "real chat session" + $threadId = \OCA\Talk\Model\Thread::THREAD_CREATE; + $threadTitle = $session->getTitle() ?? $this->getAgentDisplayName($this->getAgentByUserIdOrFail($session)); + } + } + + try { + $comment = $chatManager->sendMessage( + $room, + null, + 'users', + $session->getAgentUserId(), + $content, + new DateTime(), + replyTo: null, + referenceId: $referenceId ?? '', + threadId: $threadId, + threadTitle: $threadTitle, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to post an agent message in Talk', ['exception' => $e]); + return false; + } + + if ($threadId === \OCA\Talk\Model\Thread::THREAD_CREATE) { + // ChatManager only sets the thread metadata, the thread and the thread_created + // system message are created by the caller, like Talk's ChatController does + /** @var \OCA\Talk\Service\ThreadService $threadService */ + $threadService = Server::get(\OCA\Talk\Service\ThreadService::class); + try { + $thread = $threadService->createThread($room, (int)$comment->getId(), $threadTitle); + } catch (\Throwable $e) { + $this->logger->error('Failed to create the Talk thread of the agent session', ['exception' => $e]); + return false; + } + try { + $chatManager->addSystemMessage( + $room, + null, + 'users', + $session->getAgentUserId(), + json_encode([ + 'message' => 'thread_created', + 'parameters' => [ + 'thread' => (int)$comment->getId(), + 'title' => $thread->getName(), + ], + ]), + new DateTime(), + sendNotifications: false, + replyTo: $comment, + shouldSkipLastMessageUpdate: true, + ); + } catch (\Throwable $e) { + $this->logger->warning('Failed to post the thread_created system message', ['exception' => $e]); + } + } + + if ($threaded) { + // remember the thread, so the next messages are posted in it + $threadId = (int)($comment->getTopmostParentId() ?: $comment->getId()); + if ($session->getThreadId() !== $threadId) { + $session->setThreadId($threadId); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to store the Talk thread of the agent session', ['exception' => $e]); + } + } + } + + if ($addApproveRejectReactions) { + $this->addAgentReaction($room, $session->getAgentUserId(), (int)$comment->getId(), self::REACTION_APPROVE); + $this->addAgentReaction($room, $session->getAgentUserId(), (int)$comment->getId(), self::REACTION_REJECT); + } + return true; + } + + /** + * Replace the 👀 reaction of the agent on the last seen human message + * by the outcome emoji (✅ answer generated, 🔴 generation failed). + */ + private function updateSeenReaction(Session $session, string $reaction): void { + $seenRoomToken = $session->getAgentSeenRoomToken(); + $seenCommentId = $session->getAgentSeenCommentId(); + if ($seenRoomToken === null || $seenCommentId === null) { + return; + } + try { + $room = $this->getRoomByToken($seenRoomToken); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException $e) { + $this->logger->warning('Cannot update the reaction, the Talk room of the seen message was not found', ['token' => $seenRoomToken]); + return; + } + $this->deleteAgentReaction($room, $session->getAgentUserId(), $seenCommentId, self::REACTION_READ); + $this->addAgentReaction($room, $session->getAgentUserId(), $seenCommentId, $reaction); + // forget the seen message + $session->setAgentSeenRoomToken(null); + $session->setAgentSeenCommentId(null); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to forget the seen message of the agent session', ['exception' => $e]); + } + } + + /** + * Called by ChattyLLMTaskListener once the answer of an agent session was generated + * + * @param Session $session + * @param bool $postedInTalk Whether the answer was successfully posted in Talk + * @return void + */ + public function markGenerationCompleted(Session $session, bool $postedInTalk): void { + if ($session->getAgentUserId() === null) { + return; + } + if ($postedInTalk) { + $this->updateSeenReaction($session, self::REACTION_DONE); + } else { + $this->updateSeenReaction($session, self::REACTION_ERROR); + } + } + + /** + * Called by ChattyLLMTaskListener when the generation task of an agent session failed + * + * @param string $humanUserId + * @param int $sessionId + * @return void + */ + public function markGenerationFailed(string $humanUserId, int $sessionId): void { + try { + $session = $this->sessionMapper->getUserSession($humanUserId, $sessionId); + } catch (DoesNotExistException|Exception $e) { + $this->logger->warning('Cannot find the session of a failed agent message generation', ['sessionId' => $sessionId]); + return; + } + if ($session->getAgentUserId() === null) { + return; + } + $this->updateSeenReaction($session, self::REACTION_ERROR); + } + + private function deleteAgentReaction(\OCA\Talk\Room $room, string $agentUserId, int $messageId, string $reaction): void { + if (trim($reaction) === '' || $this->userManager->get($agentUserId) === null) { + return; + } + try { + /** @var \OCA\Talk\Chat\ReactionManager $reactionManager */ + $reactionManager = Server::get(\OCA\Talk\Chat\ReactionManager::class); + $reactionManager->deleteReactionMessage( + $room, + 'users', + $agentUserId, + $agentUserId, + $messageId, + $reaction, + ); + } catch (\Throwable $e) { + $this->logger->warning('Failed to remove an automatic reaction on an agent message in Talk', ['exception' => $e]); + } + } + + private function addAgentReaction(\OCA\Talk\Room $room, string $agentUserId, int $messageId, string $reaction): void { + $agentUser = $this->userManager->get($agentUserId); + if ($agentUser === null) { + return; + } + try { + /** @var \OCA\Talk\Chat\ReactionManager $reactionManager */ + $reactionManager = Server::get(\OCA\Talk\Chat\ReactionManager::class); + $reactionManager->addReactionMessage( + $room, + 'users', + $agentUserId, + $agentUser->getDisplayName() ?: $agentUserId, + $messageId, + $reaction, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to add an automatic reaction on an agent message in Talk', ['exception' => $e]); + } + } + + private function getRoomByToken(string $token): \OCA\Talk\Room { + /** @var \OCA\Talk\Manager $talkManager */ + $talkManager = Server::get(\OCA\Talk\Manager::class); + return $talkManager->getRoomByToken($token); + } + + private function getAgentByUserIdOrFail(Session $session): Agent { + $agent = $this->agentsService->getAgentByUserId($session->getAgentUserId()); + if ($agent === null) { + throw new AgentUserException('Agent user ' . $session->getAgentUserId() . ' not found'); + } + return $agent; + } + +} diff --git a/lib/Service/AgentUserException.php b/lib/Service/AgentUserException.php new file mode 100644 index 000000000..d9b0d84c7 --- /dev/null +++ b/lib/Service/AgentUserException.php @@ -0,0 +1,13 @@ +userManager->userExists($userId)) { + throw new AgentUserException('The user id "' . $userId . '" already exists. Please choose another one.'); + } + + $password = bin2hex(random_bytes(self::PASSWORD_LENGTH)); + try { + $user = $this->userManager->createUser($userId, $password); + } catch (\Exception $e) { + throw new AgentUserException('Failed to create the agent user "' . $userId . '": ' . $e->getMessage(), previous: $e); + } + $user->setDisplayName($name); + return $user; + } +} diff --git a/lib/Service/AgentsService.php b/lib/Service/AgentsService.php new file mode 100644 index 000000000..438f24a33 --- /dev/null +++ b/lib/Service/AgentsService.php @@ -0,0 +1,141 @@ + + * @throws Exception + */ + public function getAllAgents(): array { + return $this->agentMapper->getAllAgents(); + } + + /** + * @param int $id + * @return Agent|null + */ + public function getAgentById(int $id): ?Agent { + try { + return $this->agentMapper->getById($id); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + return null; + } + } + + /** + * @param string $userId + * @return Agent|null + */ + public function getAgentByUserId(string $userId): ?Agent { + try { + return $this->agentMapper->getByUserId($userId); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + return null; + } + } + + /** + * @param string|null $name + * @return array + * @throws Exception + */ + public function getAgentsByUserIds(array $userIds): array { + return $this->agentMapper->getByUserIds(array_values(array_filter($userIds))); + } + + /** + * @throws AgentUserException + * @throws Exception + */ + public function createAgent(string $name, string $userId, string $personality, string $scope): Agent { + $agent = new Agent(); + $agent->setUserId(trim(mb_strtolower($userId))); + $agent->setPersonality(trim($personality)); + $agent->setScope(trim($scope)); + try { + $this->agentUserService->createAgentUser($name, $userId); + } catch (AgentUserException $e) { + $this->logger->warning('Failed to provision the agent user', ['exception' => $e]); + throw $e; + } + return $this->agentMapper->insert($agent); + } + + /** + * @throws AgentUserException when the agent user does not exist anymore + * @throws Exception + */ + public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): Agent { + try { + $agent = $this->agentMapper->getById($id); + } catch (DoesNotExistException $e) { + throw new AgentUserException('Agent not found'); + } + if ($personality !== null) { + if (strlen($personality) > self::MAX_PERSONALITY_LENGTH) { + throw new AgentUserException('The agent personality is too long'); + } + $agent->setPersonality(trim($personality)); + } + if ($scope !== null) { + if (strlen($scope) > self::MAX_SCOPE_LENGTH) { + throw new AgentUserException('The agent scope is too long'); + } + $agent->setScope(trim($scope)); + } + return $this->agentMapper->update($agent); + } + + /** + * @param int $id + * @param bool $deleteUser Whether the related NC user should be deleted too + * @return void + * @throws Exception + */ + public function deleteAgent(int $id, bool $deleteUser = false): void { + try { + $agent = $this->agentMapper->getById($id); + } catch (DoesNotExistException $e) { + return; + } + $this->agentMapper->deleteAgent($agent); + + if ($deleteUser) { + $user = $this->userManager->get($agent->getUserId()); + if ($user !== null) { + $user->delete(); + } + } + } +} diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b2be960a3..fbc4e29da 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -49,7 +49,7 @@ public function __construct( * @throws InternalException * @throws UnauthorizedException */ - public function createChatSession(?string $userId, ?int $timestamp = null, ?string $title = null): Session { + public function createChatSession(?string $userId, ?int $timestamp = null, ?string $title = null, ?string $systemPrompt = null): Session { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } @@ -64,13 +64,18 @@ public function createChatSession(?string $userId, ?int $timestamp = null, ?stri $timestamp = intdiv($timestamp, 1000); } - $userInstructions = $this->appConfig->getValueString( - Application::APP_ID, - 'chat_user_instructions', - Application::CHAT_USER_INSTRUCTIONS, - lazy: true, - ) ?: Application::CHAT_USER_INSTRUCTIONS; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + $userInstructions = $systemPrompt; + if ($userInstructions === null) { + $userInstructions = $this->appConfig->getValueString( + Application::APP_ID, + 'chat_user_instructions', + Application::CHAT_USER_INSTRUCTIONS, + lazy: true, + ) ?: Application::CHAT_USER_INSTRUCTIONS; + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + } else { + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + } $session = new Session(); $session->setUserId($userId); diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 016470539..6c21f3edd 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -9,15 +9,18 @@ use OCA\Assistant\AppInfo\Application; use OCA\Assistant\Service\AgentSkillsService; +use OCA\Assistant\Service\AgentsService; use OCA\Assistant\TaskProcessing\TextToStickerTaskType; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\DB\Exception; use OCP\IAppConfig; use OCP\Settings\ISettings; use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\TaskProcessing\TaskTypes\AudioToText; use OCP\TaskProcessing\TaskTypes\TextToImage; use OCP\TaskProcessing\TaskTypes\TextToText; +use Psr\Log\LoggerInterface; class Admin implements ISettings { @@ -26,6 +29,8 @@ public function __construct( private IInitialState $initialStateService, private ITaskProcessingManager $taskProcessingManager, private AgentSkillsService $agentSkillsService, + private AgentsService $agentsService, + private LoggerInterface $logger, ) { } @@ -63,6 +68,22 @@ public function getForm(): TemplateResponse { $globalSkillsConfig = $this->agentSkillsService->getGlobalSkillsConfig(); + $agents = []; + try { + foreach ($this->agentsService->getAllAgents() as $agent) { + $user = $this->userManager->get($agent->getUserId()); + $agents[] = [ + 'id' => (int)$agent->getId(), + 'user_id' => $agent->getUserId(), + 'display_name' => $user?->getDisplayName() ?: $agent->getUserId(), + 'personality' => $agent->getPersonality(), + 'scope' => $agent->getScope(), + ]; + } + } catch (Exception $e) { + $this->logger->warning('Could not load the agents', ['exception' => $e]); + } + $adminConfig = [ 'task_processing_available' => $taskProcessingAvailable, 'assistant_enabled' => $assistantEnabled, @@ -81,6 +102,7 @@ public function getForm(): TemplateResponse { 'context_agent_available' => $contextAgentAvailable, 'global_skills_admin_uid' => $globalSkillsConfig['admin_uid'], 'global_skills_path' => $globalSkillsConfig['path'], + 'agents' => $agents, ]; $this->initialStateService->provideInitialState('admin-config', $adminConfig); diff --git a/openapi.json b/openapi.json index d3afa4da3..ed3cb7b7e 100644 --- a/openapi.json +++ b/openapi.json @@ -3887,6 +3887,1092 @@ } } }, + "/ocs/v2.php/apps/assistant/api/{apiVersion}/agents": { + "get": { + "operationId": "agents_api-list-agents", + "summary": "List all agents (admin only)", + "description": "This endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Agents returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "agents" + ], + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "display_name", + "personality", + "scope" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "personality": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "agents_api-create-agent", + "summary": "Create an agent (admin only)", + "description": "A dedicated Nextcloud user is created in the default database backend if it does not exist yet.\nThis endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "default": "", + "description": "The name of the agent" + }, + "userId": { + "type": "string", + "default": "", + "description": "The user id of the agent's dedicated Nextcloud user" + }, + "personality": { + "type": "string", + "default": "", + "description": "The agent's system prompt" + }, + "scope": { + "type": "string", + "default": "", + "description": "Optional scope for later use" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Agent created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "agent" + ], + "properties": { + "agent": { + "type": "object", + "required": [ + "id", + "user_id", + "display_name", + "personality", + "scope" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "personality": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid data", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "500": { + "description": "Agent creation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/assistant/api/{apiVersion}/agents/{id}": { + "put": { + "operationId": "agents_api-update-agent", + "summary": "Update an agent (admin only)", + "description": "This endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "personality": { + "type": "string", + "nullable": true, + "default": null, + "description": "The new personality (system prompt)" + }, + "scope": { + "type": "string", + "nullable": true, + "default": null, + "description": "The new scope" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "id", + "in": "path", + "description": "The agent id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Agent updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "agent" + ], + "properties": { + "agent": { + "type": "object", + "required": [ + "id", + "user_id", + "display_name", + "personality", + "scope" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "personality": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid data", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "agents_api-delete-agent", + "summary": "Delete an agent (admin only)", + "description": "This endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "id", + "in": "path", + "description": "The agent id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "deleteUser", + "in": "query", + "description": "Whether the related Nextcloud user should be deleted too", + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Agent deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Agent deletion failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/assistant/chat/sessions": { "post": { "operationId": "chattyllm-new-session-restful", @@ -7778,5 +8864,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "agents_api", + "description": "CRUD access to the agents of the \"agent as a user\" feature (admin only)" + } + ] } diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 6606deb3c..c2b99107d 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -231,6 +231,15 @@ {{ t('assistant', 'No global skills folder configured') }} +
+

+ {{ t('assistant', 'Talk agents') }} +

+ +
@@ -258,6 +267,8 @@ import { generateUrl } from '@nextcloud/router' import { delay } from '../utils.js' +import TalkAgentUsers from './TalkAgentUsers.vue' + export default { name: 'AdminSettings', @@ -273,6 +284,7 @@ export default { NcFormBox, NcFormBoxSwitch, OpenInNewIcon, + TalkAgentUsers, }, data() { @@ -288,6 +300,18 @@ export default { }, methods: { + onAgentCreated(agent) { + this.state.agents.push(agent) + }, + onAgentUpdated(agent) { + const index = this.state.agents.findIndex((a) => a.id === agent.id) + if (index !== -1) { + this.state.agents.splice(index, 1, agent) + } + }, + onAgentDeleted(agent) { + this.state.agents = this.state.agents.filter((a) => a.id !== agent.id) + }, isUnsignedIntStr(value) { return /^\d+$/.test(value) }, diff --git a/src/components/TalkAgentUsers.vue b/src/components/TalkAgentUsers.vue new file mode 100644 index 000000000..92e8ed15d --- /dev/null +++ b/src/components/TalkAgentUsers.vue @@ -0,0 +1,343 @@ + + + + + + diff --git a/tests/unit/Listener/ChattyLLMTaskListenerTest.php b/tests/unit/Listener/ChattyLLMTaskListenerTest.php index 845fb6141..a578b3b9b 100644 --- a/tests/unit/Listener/ChattyLLMTaskListenerTest.php +++ b/tests/unit/Listener/ChattyLLMTaskListenerTest.php @@ -14,6 +14,7 @@ use OCA\Assistant\Db\ChattyLLM\Session; use OCA\Assistant\Db\ChattyLLM\SessionMapper; use OCA\Assistant\Listener\ChattyLLMTaskListener; +use OCA\Assistant\Service\AgentTalkService; use OCA\Assistant\Service\NotificationService; use OCA\Assistant\Service\TaskProcessingService; use OCP\AppFramework\Db\DoesNotExistException; @@ -51,6 +52,7 @@ protected function setUp(): void { $this->messageMapper, $this->sessionMapper, $this->createMock(TaskProcessingService::class), + $this->createMock(AgentTalkService::class), $this->createMock(LoggerInterface::class), $this->createMock(NotificationService::class), ); diff --git a/tests/unit/Service/AgentUserServiceTest.php b/tests/unit/Service/AgentUserServiceTest.php new file mode 100644 index 000000000..b2c94cf4b --- /dev/null +++ b/tests/unit/Service/AgentUserServiceTest.php @@ -0,0 +1,58 @@ +userManager = $this->createMock(IUserManager::class); + $this->service = new AgentUserService($this->userManager); + } + + public function testCreatesUserWithRandomPasswordAndDisplayeName(): void { + $this->userManager->method('userExists')->willReturn(false); + $this->userManager->expects($this->once()) + ->method('createUser') + ->with('my_agent', $this->callback(static function (string $password) { + return strlen($password) >= 32 && ctype_alnum($password); + })) + ->willReturnCallback(function (string $userId, string $password) { + $user = $this->createConfiguredMock(IUser::class, ['getUID' => $userId]); + return $user; + }); + + $user = $this->service->createAgentUser('My Agent', 'My Agent'); + $this->assertSame('my_agent', $user->getUID()); + } + + public function testRejectsInvalidUserIds(): void { + $this->userManager->expects($this->never())->method('createUser'); + $this->expectException(AgentUserException::class); + $this->service->createAgent('Agent', 'invalid uid!'); + } + + public function testRejectsExistingUserIds(): void { + $this->userManager->method('userExists')->willReturn(true); + $this->userManager->expects($this->never())->method('createUser'); + $this->expectException(AgentUserException::class); + $this->service->createAgent('Agent', 'admin'); + } +} diff --git a/tests/unit/Service/AgentsServiceTest.php b/tests/unit/Service/AgentsServiceTest.php new file mode 100644 index 000000000..1e42b03dd --- /dev/null +++ b/tests/unit/Service/AgentsServiceTest.php @@ -0,0 +1,127 @@ +agentMapper = $this->createMock(AgentMapper::class); + $this->agentUserService = $this->createMock(AgentUserService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new AgentsService( + $this->agentMapper, + $this->agentUserService, + $this->userManager, + $this->logger, + ); + } + + public function testCreateAgentProvisionsTheUserAndStoresTheAgent(): void { + $agentUser = $this->createMock(IUser::class); + $this->agentUserService->expects($this->once()) + ->method('createAgentUser') + ->with('Code Agent', 'code_agent'); + + $stored = null; + $this->agentMapper->method('insert') + ->willReturnCallback(static function ($agent) use (&$stored) { + $agent->setId(7); + $stored = $agent; + return $agent; + }); + + $agent = $this->service->createAgent('Code Agent', 'code_agent', 'Be nice.', 'tool:code'); + $this->assertSame('code_agent', $agent->getUserId()); + $this->assertSame('Be nice.', $agent->getPersonality()); + $this->assertSame('tool:code', $agent->getScope()); + $this->assertSame(7, $agent->getId()); + } + + public function testCreateAgentFailsIfUserProvisioningFails(): void { + $this->agentUserService->expects($this->once()) + ->method('createAgentUser') + ->willThrowException(new AgentUserException('taken')); + $this->agentMapper->expects($this->never())->method('insert'); + + $this->expectException(AgentUserException::class); + $this->service->createAgent('Code Agent', 'code_agent', 'Be nice.', ''); + } + + public function testDeleteAgentWithoutUserDeletion(): void { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('deleteAgent')->with($agent); + $this->userManager->expects($this->never())->method('get'); + + $this->service->deleteAgent(5, false); + } + + public function testDeleteAgentWithUserDeletion(): void { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('deleteAgent')->with($agent); + + $user = $this->createMock(IUser::class); + $user->expects($this->once())->method('delete'); + $this->userManager->expects($this->once())->method('get')->with('agent1')->willReturn($user); + + $this->service->deleteAgent(5, true); + } + + public function testDeleteAgentKeepsTheUserIfDeleteFails(): void { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('deleteAgent') + ->willThrowException(new \OCP\DB\Exception('db down')); + $this->userManager->expects($this->never())->method('get'); + + $this->expectException(\OCP\DB\Exception::class); + $this->service->deleteAgent(5, true); + } + + public function testUpdateAgent() { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('update') + ->willReturnCallback(static function ($updated) { + return $updated; + }); + + $updated = $this->service->updateAgent(5, personality: 'New personality'); + $this->assertSame('New personality', $updated->getPersonality()); + } + + private function createAgentEntity(int $id, string $userId): \OCA\Assistant\Db\Agent { + $agent = new \OCA\Assistant\Db\Agent(); + $agent->setId($id); + $agent->setUserId($userId); + $agent->setPersonality('Be nice.'); + return $agent; + } +} From 17c18ecec5d15d2987bbbaeaf27d31679502f9a2 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 19:58:15 +0200 Subject: [PATCH 2/9] fix: psalm issue Signed-off-by: Julien Veyssier --- lib/AppInfo/Application.php | 2 ++ lib/Controller/AgentsApiController.php | 3 ++- lib/Listener/Talk/AgentMentionListener.php | 2 ++ lib/Listener/Talk/AgentReactionListener.php | 2 ++ lib/Service/AgentTalkService.php | 2 +- lib/Settings/Admin.php | 2 ++ psalm.xml | 2 ++ 7 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 32a9ed9f4..7f22bc153 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -112,7 +112,9 @@ public function register(IRegistrationContext $context): void { // the Talk event classes are not necessarily autoloadable when this app // file is registered, the listeners themselves guard for Talk being absent + /** @psalm-suppress UndefinedClass, InvalidArgument */ $context->registerEventListener(\OCA\Talk\Events\ChatMessageSentEvent::class, AgentMentionListener::class); + /** @psalm-suppress UndefinedClass, InvalidArgument */ $context->registerEventListener(\OCA\Talk\Events\ReactionAddedEvent::class, AgentReactionListener::class); if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) { diff --git a/lib/Controller/AgentsApiController.php b/lib/Controller/AgentsApiController.php index 3176e44c7..27938a094 100644 --- a/lib/Controller/AgentsApiController.php +++ b/lib/Controller/AgentsApiController.php @@ -132,12 +132,13 @@ public function createAgent(string $name = '', string $userId = '', string $pers * @param int $id The agent id * @param string|null $personality The new personality (system prompt) * @param string|null $scope The new scope - * @return DataResponse|DataResponse|DataResponse|DataResponse + * @return DataResponse|DataResponse|DataResponse|DataResponse|DataResponse * * 200: Agent updated * 400: Invalid data * 403: User is not an admin * 404: Agent not found + * 500: Agent update failed */ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): DataResponse { diff --git a/lib/Listener/Talk/AgentMentionListener.php b/lib/Listener/Talk/AgentMentionListener.php index ff38fca0f..8e88114ab 100644 --- a/lib/Listener/Talk/AgentMentionListener.php +++ b/lib/Listener/Talk/AgentMentionListener.php @@ -20,6 +20,8 @@ * Handles messages sent by users in Talk conversations with agents: * the agent answer is generated in a classic Assistant chat session * and posts back in the 1-1 Talk conversation with the human user. + * + * @psalm-suppress InvalidTemplateParam, ImplementedParamTypeMismatch */ class AgentMentionListener implements IEventListener { diff --git a/lib/Listener/Talk/AgentReactionListener.php b/lib/Listener/Talk/AgentReactionListener.php index 36c432a95..2dd389523 100644 --- a/lib/Listener/Talk/AgentReactionListener.php +++ b/lib/Listener/Talk/AgentReactionListener.php @@ -19,6 +19,8 @@ * * Handles the reactions of human users on confirmation-related agent Talk messages: * they are sent back to the agent context as approve/reject signals. + * + * @psalm-suppress InvalidTemplateParam, ImplementedParamTypeMismatch */ class AgentReactionListener implements IEventListener { diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php index 655e814a1..cf96cdc57 100644 --- a/lib/Service/AgentTalkService.php +++ b/lib/Service/AgentTalkService.php @@ -136,7 +136,7 @@ private function getAgentTarget( private function getOneToOnePartnerUserId(\OCA\Talk\Room $room, string $authorUserId): ?string { try { return $room->getSecondParticipant($authorUserId); - } catch (\InvalidArgumentException|JsonException $e) { + } catch (\InvalidArgumentException|\JsonException|\OCA\Talk\Exceptions\RoomNotFoundException $e) { return null; } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 6c21f3edd..88cfd5dbd 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -15,6 +15,7 @@ use OCP\AppFramework\Services\IInitialState; use OCP\DB\Exception; use OCP\IAppConfig; +use OCP\IUserManager; use OCP\Settings\ISettings; use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\TaskProcessing\TaskTypes\AudioToText; @@ -30,6 +31,7 @@ public function __construct( private ITaskProcessingManager $taskProcessingManager, private AgentSkillsService $agentSkillsService, private AgentsService $agentsService, + private IUserManager $userManager, private LoggerInterface $logger, ) { } diff --git a/psalm.xml b/psalm.xml index 05be2e863..aa36b04f5 100644 --- a/psalm.xml +++ b/psalm.xml @@ -38,6 +38,7 @@ + @@ -45,6 +46,7 @@ + From e29c5451ac04615d7777338783d8a3c08a45457c Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 20:00:31 +0200 Subject: [PATCH 3/9] fix: tests Signed-off-by: Julien Veyssier --- tests/unit/Service/AgentUserServiceTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/Service/AgentUserServiceTest.php b/tests/unit/Service/AgentUserServiceTest.php index b2c94cf4b..1aefaa743 100644 --- a/tests/unit/Service/AgentUserServiceTest.php +++ b/tests/unit/Service/AgentUserServiceTest.php @@ -27,7 +27,7 @@ protected function setUp(): void { $this->service = new AgentUserService($this->userManager); } - public function testCreatesUserWithRandomPasswordAndDisplayeName(): void { + public function testCreatesUserWithRandomPasswordAndDisplayName(): void { $this->userManager->method('userExists')->willReturn(false); $this->userManager->expects($this->once()) ->method('createUser') @@ -39,20 +39,20 @@ public function testCreatesUserWithRandomPasswordAndDisplayeName(): void { return $user; }); - $user = $this->service->createAgentUser('My Agent', 'My Agent'); + $user = $this->service->createAgentUser('My Agent', 'my_agent'); $this->assertSame('my_agent', $user->getUID()); } public function testRejectsInvalidUserIds(): void { $this->userManager->expects($this->never())->method('createUser'); $this->expectException(AgentUserException::class); - $this->service->createAgent('Agent', 'invalid uid!'); + $this->service->createAgentUser('Agent', 'invalid uid!'); } public function testRejectsExistingUserIds(): void { $this->userManager->method('userExists')->willReturn(true); $this->userManager->expects($this->never())->method('createUser'); $this->expectException(AgentUserException::class); - $this->service->createAgent('Agent', 'admin'); + $this->service->createAgentUser('Agent', 'admin'); } } From f1d5f2d1f8aae33aea31f9fb94067144e5da04e9 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 20:01:57 +0200 Subject: [PATCH 4/9] fix: openapi Signed-off-by: Julien Veyssier --- openapi.json | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/openapi.json b/openapi.json index ed3cb7b7e..7502818a8 100644 --- a/openapi.json +++ b/openapi.json @@ -4675,6 +4675,44 @@ } } }, + "500": { + "description": "Agent update failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { From d2d0461e7032696f9df12644113c398f054fb0ce Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 4 Nov 2026 14:28:38 +0100 Subject: [PATCH 5/9] session owner: agent, tasks are scheduled as: agent Signed-off-by: Julien Veyssier --- lib/Db/ChattyLLM/Session.php | 18 ++--- lib/Db/ChattyLLM/SessionMapper.php | 17 +++-- lib/Listener/ChattyLLMTaskListener.php | 2 +- .../Version040000Date20261023083738.php | 17 ++++- lib/Service/AgentTalkService.php | 74 ++++++++++--------- lib/Service/ChatService.php | 58 ++++++++------- tests/unit/Service/ChatServiceAgencyTest.php | 47 ++++++++++-- 7 files changed, 149 insertions(+), 84 deletions(-) diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index 5e50fa6e5..f75dc77ea 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -27,8 +27,8 @@ * @method \void setAgencyPendingActions(?string $agencyPendingActions) * @method \void setAssignmentId(?int $id) * @method \int|null getAssignmentId() - * @method \string|null getAgentUserId() - * @method \void setAgentUserId(?string $agentUserId) + * @method \string|null getRequesterUserId() + * @method \void setRequesterUserId(?string $requesterUserId) * @method \string|null getRoomToken() * @method \void setRoomToken(?string $roomToken) * @method \int|null getThreadId() @@ -74,10 +74,11 @@ class Session extends Entity implements \JsonSerializable { protected $assignmentId; /** - * When the session is used by an agent through Talk: the agent's NC user id + * When the session is owned by an agent (agent-as-a-user feature): the NC user id + * of the human user the agent converses with * @var ?string */ - protected $agentUserId; + protected $requesterUserId; /** * When the session is used by an agent through Talk: the token of the 1-1 Talk room @@ -112,8 +113,7 @@ class Session extends Entity implements \JsonSerializable { 'is_summary_up_to_date', 'is_remembered', 'assignment_id', - 'agent_user_id', - 'room_token', + 'requester_user_id', 'room_token', 'thread_id', 'agent_seen_room_token', 'agent_seen_comment_id', @@ -129,7 +129,7 @@ class Session extends Entity implements \JsonSerializable { 'isSummaryUpToDate', 'isRemembered', 'assignmentId', - 'agentUserId', + 'requesterUserId', 'roomToken', 'threadId', 'agentSeenRoomToken', @@ -146,7 +146,7 @@ public function __construct() { $this->addType('isSummaryUpToDate', Types::SMALLINT); $this->addType('isRemembered', Types::SMALLINT); $this->addType('assignmentId', Types::BIGINT); - $this->addType('agentUserId', Types::STRING); + $this->addType('requesterUserId', Types::STRING); $this->addType('roomToken', Types::STRING); $this->addType('threadId', Types::BIGINT); $this->addType('agentSeenRoomToken', Types::STRING); @@ -166,7 +166,7 @@ public function jsonSerialize() { 'is_summary_up_to_date' => $this->getIsSummaryUpToDate(), 'is_remembered' => $this->getIsRemembered(), 'assignment_id' => $this->getAssignmentId(), - 'agent_user_id' => $this->getAgentUserId(), + 'requester_user_id' => $this->getRequesterUserId(), 'room_token' => $this->getRoomToken(), 'thread_id' => $this->getThreadId(), 'agent_seen_room_token' => $this->getAgentSeenRoomToken(), diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 436cea20e..39c2f49a3 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -83,20 +83,23 @@ public function getUserSessionForAssignment(string $userId, int $assignmentId): } /** - * @param string $userId - * @param string $agentUserId + * Get the agent session owned by the given agent user for the requester + * in the origin Talk room. + * + * @param string $userId The agent user id (owner of the session) + * @param string $requesterUserId * @param string $roomToken * @return Session * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ - public function getAgentSession(string $userId, string $agentUserId, string $roomToken): Session { + public function getAgentSession(string $userId, string $requesterUserId, string $roomToken): Session { $qb = $this->db->getQueryBuilder(); $qb->select(Session::$columns) ->from($this->getTableName()) ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) - ->andWhere($qb->expr()->eq('agent_user_id', $qb->createPositionalParameter($agentUserId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('requester_user_id', $qb->createPositionalParameter($requesterUserId, IQueryBuilder::PARAM_STR))) ->andWhere($qb->expr()->eq('room_token', $qb->createPositionalParameter($roomToken, IQueryBuilder::PARAM_STR))) ->orderBy('timestamp', 'DESC') ->setMaxResults(1); @@ -104,7 +107,9 @@ public function getAgentSession(string $userId, string $agentUserId, string $roo } /** - * @param string $userId + * Get the agent session owned by the given agent user that posts in the given Talk thread. + * + * @param string $userId The agent user id (owner of the session) * @param int $threadId The Talk thread id (same as the id of the thread starter Talk comment) * @return Session * @throws DoesNotExistException @@ -117,7 +122,7 @@ public function getAgentSessionByThread(string $userId, int $threadId): Session ->from($this->getTableName()) ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) ->andWhere($qb->expr()->eq('thread_id', $qb->createPositionalParameter($threadId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->isNotNull('agent_user_id')) + ->andWhere($qb->expr()->isNotNull('requester_user_id')) ->orderBy('timestamp', 'DESC') ->setMaxResults(1); return $this->findEntity($qb); diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index 969592313..638be1246 100644 --- a/lib/Listener/ChattyLLMTaskListener.php +++ b/lib/Listener/ChattyLLMTaskListener.php @@ -199,7 +199,7 @@ public function handle(Event $event): void { // in the agent-as-a-user feature, the generated message is posted // in the 1-1 Talk conversation with the human user - if ($session->getAgentUserId() !== null) { + if ($session->getRequesterUserId() !== null) { $this->agentTalkService->postAgentTalkReply( $session, $message->getContent(), diff --git a/lib/Migration/Version040000Date20261023083738.php b/lib/Migration/Version040000Date20261023083738.php index e32b098cf..7626e0e50 100644 --- a/lib/Migration/Version040000Date20261023083738.php +++ b/lib/Migration/Version040000Date20261023083738.php @@ -53,21 +53,32 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt if ($schema->hasTable('assistant_chat_sns')) { $table = $schema->getTable('assistant_chat_sns'); - if (!$table->hasColumn('agent_user_id')) { - $table->addColumn('agent_user_id', Types::STRING, [ + // agent sessions are owned by the agent user account, + // requester_user_id is the id of the human user the agent converses with + if (!$table->hasColumn('requester_user_id')) { + $table->addColumn('requester_user_id', Types::STRING, [ 'notnull' => false, 'length' => 64, ]); + $schemaChanged = true; + } + if (!$table->hasColumn('room_token')) { $table->addColumn('room_token', Types::STRING, [ 'notnull' => false, 'length' => 64, ]); + $schemaChanged = true; + } + if (!$table->hasColumn('thread_id')) { $table->addColumn('thread_id', Types::BIGINT, [ 'notnull' => false, 'length' => 20, 'unsigned' => true, ]); - $table->addIndex(['user_id', 'agent_user_id', 'room_token'], 'assistant_chat_agent_idx'); + $schemaChanged = true; + } + if (!$table->hasIndex('assistant_chat_requester_idx')) { + $table->addIndex(['user_id', 'requester_user_id', 'room_token'], 'assistant_chat_requester_idx'); $schemaChanged = true; } } diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php index cf96cdc57..a15f6e9b7 100644 --- a/lib/Service/AgentTalkService.php +++ b/lib/Service/AgentTalkService.php @@ -162,8 +162,8 @@ private function processAgentInteraction( $triggerThreadId = (int)($comment->getTopmostParentId() ?? 0); if ($triggerThreadId !== 0) { try { - $threadSession = $this->sessionMapper->getAgentSessionByThread($humanUserId, $triggerThreadId); - $this->storeAndSchedule($threadSession, $comment, $humanUserId, $room, $agent); + $threadSession = $this->sessionMapper->getAgentSessionByThread($agent->getUserId(), $triggerThreadId); + $this->storeAndSchedule($threadSession, $comment, $room, $agent); return; } catch (DoesNotExistException) { // the reply is not in the thread of an agent session: handle it below @@ -183,7 +183,7 @@ private function processAgentInteraction( // find or create the assistant chat session dedicated to this agent/origin-room context $session = $this->getOrCreateAgentSession($humanUserId, $agent, $contextRoomName, $room->getToken()); - $this->storeAndSchedule($session, $comment, $humanUserId, $room, $agent); + $this->storeAndSchedule($session, $comment, $room, $agent); } /** @@ -193,12 +193,12 @@ private function processAgentInteraction( * @throws NotFoundException * @throws BadRequestException */ - private function storeAndSchedule(Session $session, IComment $comment, string $humanUserId, \OCA\Talk\Room $room, Agent $agent): void { + private function storeAndSchedule(Session $session, IComment $comment, \OCA\Talk\Room $room, Agent $agent): void { // store the human Talk message in the chat session $creationTimestamp = $comment->getCreationDateTime()->getTimestamp(); $talkContent = (string)$comment->getMessage(); $this->chatService->createMessage( - $humanUserId, + $session->getUserId(), (int)$session->getId(), Message::ROLE_HUMAN, $talkContent, @@ -220,7 +220,7 @@ private function storeAndSchedule(Session $session, IComment $comment, string $h } // schedule generation of the agent answer - $this->chatService->scheduleMessageGeneration($humanUserId, (int)$session->getId()); + $this->chatService->scheduleMessageGeneration($session->getUserId(), (int)$session->getId()); } /** @@ -229,12 +229,15 @@ private function storeAndSchedule(Session $session, IComment $comment, string $h * All the agent answers are posted as one Talk thread of the 1-1 room * between the human and the agent. * + * Agent sessions are owned by the agent user account, + * the human user is recorded as the requester. + * * @throws Exception */ private function getOrCreateAgentSession(string $humanUserId, Agent $agent, string $contextRoomName, string $originRoomToken): Session { try { return $this->sessionMapper->getAgentSession( - $humanUserId, $agent->getUserId(), $originRoomToken + $agent->getUserId(), $humanUserId, $originRoomToken ); } catch (DoesNotExistException) { // continue with session creation @@ -246,12 +249,12 @@ private function getOrCreateAgentSession(string $humanUserId, Agent $agent, stri $sessionTitle = $agentName . self::THREAD_TITLE_SEPARATOR . $contextRoomName; $session = $this->chatService->createChatSession( - $humanUserId, + $agent->getUserId(), timestamp: null, title: $sessionTitle, systemPrompt: $agent->getPersonality(), + requesterUserId: $humanUserId, ); - $session->setAgentUserId($agent->getUserId()); $session->setRoomToken($originRoomToken); try { $this->sessionMapper->update($session); @@ -313,21 +316,21 @@ public function postAgentTalkReply( string $content, ?string $pendingActions, ): void { - if ($session->getAgentUserId() === null) { + if ($session->getRequesterUserId() === null) { return; } // only for agent sessions if (!class_exists('OCA\Talk\Chat\ChatManager')) { return; } - $agentUser = $this->userManager->get($session->getAgentUserId()); + $agentUser = $this->userManager->get($session->getUserId()); if ($agentUser === null) { $this->logger->warning('The agent user was deleted while an agent answer was being handled'); return; } - $humanUser = $this->userManager->get($session->getUserId()); + $humanUser = $this->userManager->get($session->getRequesterUserId()); if ($humanUser === null) { - $this->logger->warning('The session owner user was deleted while an agent answer was being handled'); + $this->logger->warning('The requester user was deleted while an agent answer was being handled'); return; } try { @@ -396,7 +399,7 @@ public function handleConfirmationReaction(string $humanUserId, int $sessionId, return; } try { - $session = $this->sessionMapper->getUserSession($humanUserId, $sessionId); + $session = $this->sessionMapper->getSessionById($sessionId); } catch (DoesNotExistException|Exception $e) { $this->logger->warning('Could not find the session matching the Talk reaction', [ 'humanUserId' => $humanUserId, @@ -404,15 +407,20 @@ public function handleConfirmationReaction(string $humanUserId, int $sessionId, ]); return; } - if ($session->getAgentUserId() === null) { + if ($session->getRequesterUserId() === null) { + // the session is not an agent session + return; + } + if ($session->getRequesterUserId() !== $humanUserId) { + // the reaction was not added by the human user the agent session is dedicated to return; } // remove the agent's own thumbs so only the one of the user remains, // then mark the confirmation as being processed - $this->deleteAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_APPROVE); - $this->deleteAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_REJECT); - $this->addAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_READ); + $this->deleteAgentReaction($room, $session->getUserId(), $messageId, self::REACTION_APPROVE); + $this->deleteAgentReaction($room, $session->getUserId(), $messageId, self::REACTION_REJECT); + $this->addAgentReaction($room, $session->getUserId(), $messageId, self::REACTION_READ); // remember the seen message to later replace 👀 by ✅/🔴 after the confirmation run $session->setAgentSeenRoomToken($room->getToken()); $session->setAgentSeenCommentId($messageId); @@ -423,7 +431,7 @@ public function handleConfirmationReaction(string $humanUserId, int $sessionId, } try { $this->chatService->scheduleMessageGeneration( - $humanUserId, + $session->getUserId(), $sessionId, $reaction === self::REACTION_APPROVE ? 1 : 0, ); @@ -443,7 +451,7 @@ private function postAgentMessage( if (trim($content) === '') { return false; } - $agentUser = $this->userManager->get($session->getAgentUserId()); + $agentUser = $this->userManager->get($session->getUserId()); if ($agentUser === null) { return false; } @@ -468,7 +476,7 @@ private function postAgentMessage( $room, null, 'users', - $session->getAgentUserId(), + $session->getUserId(), $content, new DateTime(), replyTo: null, @@ -497,7 +505,7 @@ private function postAgentMessage( $room, null, 'users', - $session->getAgentUserId(), + $session->getUserId(), json_encode([ 'message' => 'thread_created', 'parameters' => [ @@ -529,8 +537,8 @@ private function postAgentMessage( } if ($addApproveRejectReactions) { - $this->addAgentReaction($room, $session->getAgentUserId(), (int)$comment->getId(), self::REACTION_APPROVE); - $this->addAgentReaction($room, $session->getAgentUserId(), (int)$comment->getId(), self::REACTION_REJECT); + $this->addAgentReaction($room, $session->getUserId(), (int)$comment->getId(), self::REACTION_APPROVE); + $this->addAgentReaction($room, $session->getUserId(), (int)$comment->getId(), self::REACTION_REJECT); } return true; } @@ -551,8 +559,8 @@ private function updateSeenReaction(Session $session, string $reaction): void { $this->logger->warning('Cannot update the reaction, the Talk room of the seen message was not found', ['token' => $seenRoomToken]); return; } - $this->deleteAgentReaction($room, $session->getAgentUserId(), $seenCommentId, self::REACTION_READ); - $this->addAgentReaction($room, $session->getAgentUserId(), $seenCommentId, $reaction); + $this->deleteAgentReaction($room, $session->getUserId(), $seenCommentId, self::REACTION_READ); + $this->addAgentReaction($room, $session->getUserId(), $seenCommentId, $reaction); // forget the seen message $session->setAgentSeenRoomToken(null); $session->setAgentSeenCommentId(null); @@ -571,7 +579,7 @@ private function updateSeenReaction(Session $session, string $reaction): void { * @return void */ public function markGenerationCompleted(Session $session, bool $postedInTalk): void { - if ($session->getAgentUserId() === null) { + if ($session->getUserId() === null) { return; } if ($postedInTalk) { @@ -584,18 +592,18 @@ public function markGenerationCompleted(Session $session, bool $postedInTalk): v /** * Called by ChattyLLMTaskListener when the generation task of an agent session failed * - * @param string $humanUserId + * @param string $sessionOwnerUserId The agent user id owning the session * @param int $sessionId * @return void */ - public function markGenerationFailed(string $humanUserId, int $sessionId): void { + public function markGenerationFailed(string $sessionOwnerUserId, int $sessionId): void { try { - $session = $this->sessionMapper->getUserSession($humanUserId, $sessionId); + $session = $this->sessionMapper->getUserSession($sessionOwnerUserId, $sessionId); } catch (DoesNotExistException|Exception $e) { $this->logger->warning('Cannot find the session of a failed agent message generation', ['sessionId' => $sessionId]); return; } - if ($session->getAgentUserId() === null) { + if ($session->getRequesterUserId() === null) { return; } $this->updateSeenReaction($session, self::REACTION_ERROR); @@ -649,9 +657,9 @@ private function getRoomByToken(string $token): \OCA\Talk\Room { } private function getAgentByUserIdOrFail(Session $session): Agent { - $agent = $this->agentsService->getAgentByUserId($session->getAgentUserId()); + $agent = $this->agentsService->getAgentByUserId($session->getUserId()); if ($agent === null) { - throw new AgentUserException('Agent user ' . $session->getAgentUserId() . ' not found'); + throw new AgentUserException('Agent user ' . $session->getUserId() . ' not found'); } return $agent; } diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index fbc4e29da..9f11c919d 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -49,7 +49,7 @@ public function __construct( * @throws InternalException * @throws UnauthorizedException */ - public function createChatSession(?string $userId, ?int $timestamp = null, ?string $title = null, ?string $systemPrompt = null): Session { + public function createChatSession(?string $userId, ?int $timestamp = null, ?string $title = null, ?string $systemPrompt = null, ?string $requesterUserId = null): Session { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } @@ -57,6 +57,10 @@ public function createChatSession(?string $userId, ?int $timestamp = null, ?stri if ($user === null) { throw new UnauthorizedException($this->l10n->t('User not found')); } + // agent sessions are owned by the agent user account, + // the user-related placeholders address the human requester + $placeholderUserId = $requesterUserId ?? $userId; + $placeholderUser = $placeholderUserId === $userId ? $user : $this->userManager->get($placeholderUserId); $timestamp ??= $this->timeFactory->now()->getTimestamp(); @@ -72,10 +76,8 @@ public function createChatSession(?string $userId, ?int $timestamp = null, ?stri Application::CHAT_USER_INSTRUCTIONS, lazy: true, ) ?: Application::CHAT_USER_INSTRUCTIONS; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); - } else { - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); } + $userInstructions = str_replace('{user}', $placeholderUser?->getDisplayName() ?: $placeholderUserId, $userInstructions); $session = new Session(); $session->setUserId($userId); @@ -83,6 +85,7 @@ public function createChatSession(?string $userId, ?int $timestamp = null, ?stri $session->setTimestamp($timestamp); $session->setAgencyConversationToken(null); $session->setAgencyPendingActions(null); + $session->setRequesterUserId($requesterUserId); try { $this->sessionMapper->insert($session); } catch (Exception $e) { @@ -410,13 +413,15 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ throw new UnauthorizedException($this->l10n->t('Unauthorized')); } try { - $sessionExists = $this->sessionMapper->exists($userId, $sessionId); - } catch (Exception $e) { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { throw new InternalException(previous: $e); } - if (!$sessionExists) { - throw new NotFoundException($this->l10n->t('Session not found')); - } + // agent sessions belong to the agent user account: the memories to inject + // come from the human requester, not from the agent + $memoriesUserId = $session->getRequesterUserId() ?? $userId; if ($this->isContextAgentAvailable()) { try { @@ -427,13 +432,6 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ throw new InternalException(previous: $e); } - try { - $session = $this->sessionMapper->getUserSession($userId, $sessionId); - } catch (DoesNotExistException $e) { - throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); - } catch (MultipleObjectsReturnedException|Exception $e) { - throw new InternalException(previous: $e); - } $lastConversationToken = $session->getAgencyConversationToken() ?? self::EMPTY_CONVERSATION_TOKEN; $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; @@ -446,18 +444,18 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ ) { // audio agency $fileId = $audioAttachment['file_id']; - $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId()); + $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId(), $userId); } elseif ($this->isMultimodalContextAgentAvailable()) { // multimodal agency $prompt = $lastUserMessage->getContent(); $inputAttachments = array_map(static function (array $attachment) { return $attachment['file_id']; }, $lastAttachments); - $taskId = $this->scheduleAgencyMultimodalTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, $inputAttachments); + $taskId = $this->scheduleAgencyMultimodalTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, $inputAttachments, $userId); } else { // classic agency $prompt = $lastUserMessage->getContent(); - $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId); + $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, memoriesUserId: $userId); } } else { // classic chat @@ -489,7 +487,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ // for an audio chat task, let's try to get the remote audio IDs for all the previous audio messages $history = $this->getAudioHistory($history); $fileId = $audioAttachment['file_id']; - $taskId = $this->scheduleAudioChatTask($userId, $fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId()); + $taskId = $this->scheduleAudioChatTask($userId, $fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId(), $userId); } else { if ($this->isMultimodalChatAvailable()) { // for a multimodal chat also attachments need to be added to the history @@ -514,7 +512,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ $lastAttachments = array_map(static function (array $attachment) { return $attachment['file_id']; }, $lastAttachments); - $taskId = $this->scheduleMultimodalChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, $lastAttachments); + $taskId = $this->scheduleMultimodalChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, $lastAttachments, $userId); } else { // for a text chat task only use text in the history $historyMessages = array_map(static function (Message $message) { @@ -523,7 +521,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ 'content' => $message->getContent(), ]); }, $history); - $taskId = $this->scheduleLLMChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId); + $taskId = $this->scheduleLLMChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, memoriesUserId: $userId); } } } @@ -775,6 +773,7 @@ private function scheduleLLMChatTask( array $history, int $sessionId, bool $isMessage = true, + ?string $memoriesUserId = null, ): int { $customId = ($isMessage ? 'chatty-llm:' @@ -786,7 +785,7 @@ private function scheduleLLMChatTask( 'history' => $history, ]; if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) { - $input['memories'] = $this->sessionSummaryService->getMemories($userId); + $input['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); } $task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $userId, $customId); /** @psalm-suppress UndefinedMethod */ @@ -819,6 +818,7 @@ private function scheduleMultimodalChatTask( array $history, int $sessionId, array $attachmentsHistory, + ?string $memoriesUserId = null, ): int { $customId = 'chatty-llm:' . $sessionId; $this->checkIfSessionIsThinking($userId, $customId); @@ -862,6 +862,7 @@ private function scheduleAgencyTask( string $conversationToken, int $sessionId, bool $withUserInstructions = true, + ?string $memoriesUserId = null, ): int { $customId = 'chatty-llm:' . $sessionId; $this->checkIfSessionIsThinking($userId, $customId); @@ -873,7 +874,7 @@ private function scheduleAgencyTask( /** @psalm-suppress UndefinedClass */ $optionalInputShape = $this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape'] ?? []; if (isset($optionalInputShape['memories'])) { - $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + $taskInput['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); } if ($withUserInstructions) { $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $sessionId); @@ -917,6 +918,7 @@ private function scheduleAgencyMultimodalTask( string $conversationToken, int $sessionId, array $inputAttachments, + ?string $memoriesUserId = null, ): int { $customId = 'chatty-llm:' . $sessionId; $this->checkIfSessionIsThinking($userId, $customId); @@ -926,7 +928,7 @@ private function scheduleAgencyMultimodalTask( 'confirmation' => $confirmation, 'conversation_token' => $conversationToken, ]; - $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + $taskInput['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); /** @psalm-suppress UndefinedClass */ $optionalInputShape = $this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\MultimodalContextAgentInteraction::ID]['optionalInputShape'] ?? []; $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $sessionId); @@ -967,6 +969,7 @@ private function scheduleAudioChatTask( array $history, int $sessionId, int $queryMessageId, + ?string $memoriesUserId = null, ): int { $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; $this->checkIfSessionIsThinking($userId, $customId); @@ -977,7 +980,7 @@ private function scheduleAudioChatTask( ]; /** @psalm-suppress UndefinedClass */ if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) { - $input['memories'] = $this->sessionSummaryService->getMemories($userId); + $input['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); } /** @psalm-suppress UndefinedClass */ $task = new Task( @@ -1014,6 +1017,7 @@ private function scheduleAgencyAudioTask( string $conversationToken, int $sessionId, int $queryMessageId, + ?string $memoriesUserId = null, ): int { $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; $this->checkIfSessionIsThinking($userId, $customId); @@ -1025,7 +1029,7 @@ private function scheduleAgencyAudioTask( /** @psalm-suppress UndefinedClass */ $optionalInputShape = $this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape'] ?? []; if (isset($optionalInputShape['memories'])) { - $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + $taskInput['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); } $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $sessionId); /** @psalm-suppress UndefinedClass */ diff --git a/tests/unit/Service/ChatServiceAgencyTest.php b/tests/unit/Service/ChatServiceAgencyTest.php index ea5cfab99..8c6cee194 100644 --- a/tests/unit/Service/ChatServiceAgencyTest.php +++ b/tests/unit/Service/ChatServiceAgencyTest.php @@ -41,16 +41,18 @@ class ChatServiceAgencyTest extends TestCase { private ChatService $service; private MessageMapper&MockObject $messageMapper; private IManager&MockObject $taskProcessingManager; + private SessionMapper&MockObject $sessionMapper; + private SessionSummaryService&MockObject $sessionSummaryService; private ?Task $scheduledTask = null; protected function setUp(): void { parent::setUp(); - $sessionMapper = $this->createMock(SessionMapper::class); - $sessionMapper->method('exists')->willReturn(true); + $this->sessionMapper = $this->createMock(SessionMapper::class); + $this->sessionMapper->method('exists')->willReturn(true); $session = new Session(); $session->setUserId(self::USER_ID); - $sessionMapper->method('getUserSession')->willReturn($session); + $this->sessionMapper->method('getUserSession')->willReturn($session); $humanMessage = new Message(); $humanMessage->setSessionId(self::SESSION_ID); @@ -68,20 +70,26 @@ protected function setUp(): void { $this->scheduledTask = $task; }); + $this->sessionSummaryService = $this->createMock(SessionSummaryService::class); + $this->service = new ChatService( $this->createMock(IUserManager::class), $this->createMock(IAppConfig::class), $this->createMock(IL10N::class), - $sessionMapper, + $this->sessionMapper, $this->messageMapper, $this->createMock(IDBConnection::class), - $this->createMock(SessionSummaryService::class), + $this->sessionSummaryService, $this->taskProcessingManager, $this->createMock(LoggerInterface::class), $this->createMock(ITimeFactory::class), ); } + private function setUserSession(Session $session): void { + $this->sessionMapper->method('getUserSession')->willReturn($session); + } + private function setAgentOptionalInputs(array $optionalInputShape): void { $this->taskProcessingManager->method('getAvailableTaskTypes')->willReturn([ ContextAgentInteraction::ID => ['optionalInputShape' => $optionalInputShape], @@ -151,4 +159,33 @@ public function testUserInstructionsAreNotSentForAssignmentRuns(): void { $this->assertSame(ContextAgentInteraction::ID, $this->scheduledTask->getTaskTypeId()); $this->assertArrayNotHasKey('user_instructions', $this->scheduledTask->getInput()); } + + public function testTaskIsScheduledAsTheSessionOwner(): void { + $this->setAgentOptionalInputs([]); + + $this->service->scheduleMessageGeneration(self::USER_ID, self::SESSION_ID); + + $this->assertNotNull($this->scheduledTask); + $this->assertSame(self::USER_ID, $this->scheduledTask->getUserId()); + } + + public function testAgentSessionsScheduleAsTheAgentOwnerWithTheRequesterMemories(): void { + $agentUserId = 'agent_user_id'; + $session = new Session(); + $session->setUserId($agentUserId); + $session->setRequesterUserId(self::USER_ID); + $this->setUserSession($session); + $this->setAgentOptionalInputs(['memories' => 'shape']); + $this->messageMapper->method('getFirstNMessages')->willThrowException(new DoesNotExistException('no system message')); + $this->sessionSummaryService->expects($this->once()) + ->method('getMemories') + ->with(self::USER_ID) + ->willReturn([]); + + $this->service->scheduleMessageGeneration($agentUserId, self::SESSION_ID); + + $this->assertNotNull($this->scheduledTask); + $this->assertSame($agentUserId, $this->scheduledTask->getUserId()); + $this->assertArrayHasKey('memories', $this->scheduledTask->getInput()); + } } From 457f72954b53ec7ff44c6f1b9793f4249367cfb9 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 4 Nov 2026 15:06:57 +0100 Subject: [PATCH 6/9] answer the mentions in the initial context, point to the real answer in the 1-1 thread Signed-off-by: Julien Veyssier --- lib/Service/AgentTalkService.php | 111 ++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php index a15f6e9b7..9a6f88ca3 100644 --- a/lib/Service/AgentTalkService.php +++ b/lib/Service/AgentTalkService.php @@ -17,6 +17,8 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\Comments\IComment; use OCP\DB\Exception; +use OCP\IL10N; +use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\Server; @@ -44,6 +46,8 @@ public function __construct( private AgentsService $agentsService, private ChatService $chatService, private SessionMapper $sessionMapper, + private IURLGenerator $urlGenerator, + private IL10N $l10n, private LoggerInterface $logger, ) { } @@ -356,7 +360,7 @@ public function postAgentTalkReply( } } - $posted = $this->postAgentMessage( + $postedComment = $this->postAgentMessage( $room, $session, $content, @@ -373,11 +377,100 @@ public function postAgentTalkReply( referenceId: self::CONFIRMATION_REFERENCE_PREFIX . $session->getId() . ':' . time(), addApproveRejectReactions: true, threaded: $threaded, - ); + ) !== null; + } + + if ($postedComment !== null) { + try { + $this->postGroupRoomPointer($session, $room, $postedComment); + } catch (\Throwable $e) { + $this->logger->error('Failed to post the answer pointer message in the origin Talk room', ['exception' => $e]); + } } // an agency confirmation turn has no reply content, only the confirmation message - $this->markGenerationCompleted($session, $posted || $confirmPosted); + $this->markGenerationCompleted($session, $postedComment !== null || $confirmPosted); + } + + /** + * When the answer was requested by an @mention from a group Talk room, + * post a short pointer message there, as the agent, in reply to the mention: + * it mentions the human user and links to the answer posted + * in the private 1-1 conversation with the agent. + * + * Messages sent in the 1-1 conversation or in its threads do not trigger + * a pointer: their answers are posted in that same conversation. + */ + private function postGroupRoomPointer(Session $session, \OCA\Talk\Room $oneToOneRoom, IComment $answerComment): void { + $seenRoomToken = $session->getAgentSeenRoomToken(); + $seenCommentId = $session->getAgentSeenCommentId(); + if ($seenRoomToken === null || $seenCommentId === null) { + return; + } + try { + $seenRoom = $this->getRoomByToken($seenRoomToken); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException $e) { + $this->logger->warning('Cannot post the answer pointer message, the Talk room of the mention was not found', ['token' => $seenRoomToken]); + return; + } + if ($seenRoom->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) { + // the message was sent in the 1-1 conversation or one of its threads, + // the answer was posted there: no pointer message needed + return; + } + $requesterUserId = $session->getRequesterUserId(); + if ($requesterUserId === null) { + return; + } + + // the mention message to reply to must be a chat message of the handled room + try { + /** @var \OCA\Talk\Chat\CommentsManager $commentsManager */ + $commentsManager = Server::get(\OCA\Talk\Chat\CommentsManager::class); + $mentionComment = $commentsManager->getCommentsById([(string)$seenCommentId])[(string)$seenCommentId] ?? null; + } catch (\Throwable $e) { + $this->logger->warning('Cannot post the answer pointer message, the handled Talk message was not found', ['commentId' => $seenCommentId]); + return; + } + if ($mentionComment === null) { + return; + } + if ($mentionComment->getObjectType() !== 'chat' + || $mentionComment->getObjectId() !== (string)$seenRoom->getId() + || $mentionComment->getActorType() !== 'users' + || $mentionComment->getActorId() !== $requesterUserId) { + return; + } + + $urlParams = [ + 'token' => $oneToOneRoom->getToken(), + '_fragment' => 'message_' . $answerComment->getId(), + ]; + // like Talk's notification links: thread context is needed to open the message + $threadId = (int)($answerComment->getMetaData()[\OCA\Talk\Model\Message::METADATA_THREAD_ID] ?? 0); + if ($threadId !== 0) { + $urlParams['threadId'] = $threadId; + } + $link = $this->urlGenerator->linkToRouteAbsolute('spreed.Page.showCall', $urlParams); + $content = '@"' . $requesterUserId . '" ' + . $this->l10n->t('I answered to you in our private conversation in the thread "%s"', [$session->getTitle() ?? '']) + . '. ' . $link; + + try { + /** @var \OCA\Talk\Chat\ChatManager $chatManager */ + $chatManager = Server::get(\OCA\Talk\Chat\ChatManager::class); + $chatManager->sendMessage( + $seenRoom, + null, + 'users', + $session->getUserId(), + $content, + new DateTime(), + replyTo: $mentionComment, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to post the answer pointer message in the Talk room', ['exception' => $e]); + } } /** @@ -447,13 +540,13 @@ private function postAgentMessage( ?string $referenceId = null, bool $addApproveRejectReactions = false, bool $threaded = true, - ): bool { + ): ?IComment { if (trim($content) === '') { - return false; + return null; } $agentUser = $this->userManager->get($session->getUserId()); if ($agentUser === null) { - return false; + return null; } /** @var \OCA\Talk\Chat\ChatManager $chatManager */ $chatManager = Server::get(\OCA\Talk\Chat\ChatManager::class); @@ -486,7 +579,7 @@ private function postAgentMessage( ); } catch (\Throwable $e) { $this->logger->error('Failed to post an agent message in Talk', ['exception' => $e]); - return false; + return null; } if ($threadId === \OCA\Talk\Model\Thread::THREAD_CREATE) { @@ -498,7 +591,7 @@ private function postAgentMessage( $thread = $threadService->createThread($room, (int)$comment->getId(), $threadTitle); } catch (\Throwable $e) { $this->logger->error('Failed to create the Talk thread of the agent session', ['exception' => $e]); - return false; + return null; } try { $chatManager->addSystemMessage( @@ -540,7 +633,7 @@ private function postAgentMessage( $this->addAgentReaction($room, $session->getUserId(), (int)$comment->getId(), self::REACTION_APPROVE); $this->addAgentReaction($room, $session->getUserId(), (int)$comment->getId(), self::REACTION_REJECT); } - return true; + return $comment; } /** From 9caa64706a7c9043efda7304917a86ef03e4b67e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 4 Nov 2026 15:09:22 +0100 Subject: [PATCH 7/9] fix mistake with memories Signed-off-by: Julien Veyssier --- lib/Service/ChatService.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index 9f11c919d..bd43176d9 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -444,18 +444,18 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ ) { // audio agency $fileId = $audioAttachment['file_id']; - $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId(), $userId); + $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId(), $memoriesUserId); } elseif ($this->isMultimodalContextAgentAvailable()) { // multimodal agency $prompt = $lastUserMessage->getContent(); $inputAttachments = array_map(static function (array $attachment) { return $attachment['file_id']; }, $lastAttachments); - $taskId = $this->scheduleAgencyMultimodalTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, $inputAttachments, $userId); + $taskId = $this->scheduleAgencyMultimodalTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, $inputAttachments, $memoriesUserId); } else { // classic agency $prompt = $lastUserMessage->getContent(); - $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, memoriesUserId: $userId); + $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, memoriesUserId: $memoriesUserId); } } else { // classic chat @@ -487,7 +487,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ // for an audio chat task, let's try to get the remote audio IDs for all the previous audio messages $history = $this->getAudioHistory($history); $fileId = $audioAttachment['file_id']; - $taskId = $this->scheduleAudioChatTask($userId, $fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId(), $userId); + $taskId = $this->scheduleAudioChatTask($userId, $fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId(), $memoriesUserId); } else { if ($this->isMultimodalChatAvailable()) { // for a multimodal chat also attachments need to be added to the history @@ -512,7 +512,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ $lastAttachments = array_map(static function (array $attachment) { return $attachment['file_id']; }, $lastAttachments); - $taskId = $this->scheduleMultimodalChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, $lastAttachments, $userId); + $taskId = $this->scheduleMultimodalChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, $lastAttachments, $memoriesUserId); } else { // for a text chat task only use text in the history $historyMessages = array_map(static function (Message $message) { @@ -521,7 +521,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ 'content' => $message->getContent(), ]); }, $history); - $taskId = $this->scheduleLLMChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, memoriesUserId: $userId); + $taskId = $this->scheduleLLMChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $historyMessages, $sessionId, memoriesUserId: $memoriesUserId); } } } From 590c57f1f8f586df55c8e9e262063e7b4c5b8cd4 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 4 Nov 2026 15:37:03 +0100 Subject: [PATCH 8/9] let the agent know who it is, who it is speaking to and where it was mentioned initially Signed-off-by: Julien Veyssier --- lib/Service/AgentTalkService.php | 27 ++++++++- lib/Service/ChatService.php | 61 +++++++++++++++++--- tests/unit/Service/ChatServiceAgencyTest.php | 44 +++++++++++++- 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php index 9a6f88ca3..c4fa7b344 100644 --- a/lib/Service/AgentTalkService.php +++ b/lib/Service/AgentTalkService.php @@ -224,7 +224,7 @@ private function storeAndSchedule(Session $session, IComment $comment, \OCA\Talk } // schedule generation of the agent answer - $this->chatService->scheduleMessageGeneration($session->getUserId(), (int)$session->getId()); + $this->chatService->scheduleMessageGeneration($session->getUserId(), (int)$session->getId(), 0, $this->getOriginRoomInfo($session)); } /** @@ -527,6 +527,7 @@ public function handleConfirmationReaction(string $humanUserId, int $sessionId, $session->getUserId(), $sessionId, $reaction === self::REACTION_APPROVE ? 1 : 0, + $this->getOriginRoomInfo($session), ); } catch (InternalException|NotFoundException|BadRequestException|UnauthorizedException $e) { $this->logger->error('Failed to schedule the confirmation follow-up after a Talk reaction', ['exception' => $e]); @@ -749,6 +750,30 @@ private function getRoomByToken(string $token): \OCA\Talk\Room { return $talkManager->getRoomByToken($token); } + /** + * The name of the Talk room where the conversation of an agent session + * started, when the origin room is a group room. The identity context of + * the generation tasks mentions it, no other task attribute does. + * + * @param Session $session + * @return string|null + */ + private function getOriginRoomInfo(Session $session): ?string { + if ($session->getRoomToken() === null) { + return null; + } + try { + $originRoom = $this->getRoomByToken($session->getRoomToken()); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException) { + return null; + } + if ($originRoom->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) { + // 1-1 conversations need no origin mention, everything happens there + return null; + } + return $originRoom->getName(); + } + private function getAgentByUserIdOrFail(Session $session): Agent { $agent = $this->agentsService->getAgentByUserId($session->getUserId()); if ($agent === null) { diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index bd43176d9..310e57779 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -408,7 +408,7 @@ public function deleteSessionMessage(?string $userId, int $sessionId, int $messa * @throws NotFoundException * @throws UnauthorizedException */ - public function scheduleMessageGeneration(?string $userId, int $sessionId, int $agencyConfirm = 0): int { + public function scheduleMessageGeneration(?string $userId, int $sessionId, int $agencyConfirm = 0, ?string $originRoomInfo = null): int { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } @@ -424,6 +424,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ $memoriesUserId = $session->getRequesterUserId() ?? $userId; if ($this->isContextAgentAvailable()) { + $identityLine = $this->getIdentityLine($session, $originRoomInfo); try { $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); } catch (DoesNotExistException $e) { @@ -444,22 +445,26 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ ) { // audio agency $fileId = $audioAttachment['file_id']; - $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId(), $memoriesUserId); + $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId(), $memoriesUserId, $identityLine); } elseif ($this->isMultimodalContextAgentAvailable()) { // multimodal agency $prompt = $lastUserMessage->getContent(); $inputAttachments = array_map(static function (array $attachment) { return $attachment['file_id']; }, $lastAttachments); - $taskId = $this->scheduleAgencyMultimodalTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, $inputAttachments, $memoriesUserId); + $taskId = $this->scheduleAgencyMultimodalTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, $inputAttachments, $memoriesUserId, $identityLine); } else { // classic agency $prompt = $lastUserMessage->getContent(); - $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, memoriesUserId: $memoriesUserId); + $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId, memoriesUserId: $memoriesUserId, identityLine: $identityLine); } } else { // classic chat + $identityLine = $this->getIdentityLine($session, $originRoomInfo); $systemPrompt = $this->getSessionSystemPrompt($sessionId); + if ($identityLine !== null) { + $systemPrompt = $identityLine . "\n\n" . $systemPrompt; + } try { $history = $this->getRawLastMessages($sessionId); } catch (Exception|AppConfigTypeConflictException $e) { @@ -669,20 +674,55 @@ private function getSessionSystemPrompt(int $sessionId): string { return ''; } + /** + * Agent sessions are owned by the agent user account: without an explicit + * statement, the model neither knows which agent it is, nor which human + * user it is talking with. This describes both identities. + * + * @param Session $session + * @param string|null $originRoomInfo The name of the Talk room where the + * conversation started, when it is not + * a 1-1 conversation + * @return string|null Null for sessions that are not agent sessions + */ + private function getIdentityLine(Session $session, ?string $originRoomInfo): ?string { + $requesterUserId = $session->getRequesterUserId(); + if ($requesterUserId === null) { + return null; + } + $agentUserId = $session->getUserId(); + $agentDisplayName = $this->userManager->get($agentUserId)?->getDisplayName() ?: $agentUserId; + $requesterDisplayName = $this->userManager->get($requesterUserId)?->getDisplayName() ?: $requesterUserId; + $identityLine = $this->l10n->t('You, the assistant, are "%1$s" (account "@%2$s").', [$agentDisplayName, $agentUserId]) + . ' ' . $this->l10n->t('You are assisting %1$s (account "@%2$s") through Talk.', [$requesterDisplayName, $requesterUserId]) + . ' ' . $this->l10n->t('When asked who you are talking with, e.g. "who am I" or "who are you talking to", refer to the human identity. When asked "who are you", refer to your own assistant identity.'); + if ($originRoomInfo !== null) { + $identityLine .= ' ' . $this->l10n->t('The conversation started in the Talk room "%s".', [$originRoomInfo]); + } + return $identityLine; + } + /** * Pass the session's user instructions to an agency task if its provider supports them. * The agent appends them to its own system prompt, they do not replace it. * If they cannot be read, the task is scheduled without them. + * The identity line is passed by agent sessions, so the model knows + * which assistant agent it is and which human user it is talking with. */ - private function addAgencyUserInstructions(array &$taskInput, array $optionalInputShape, int $sessionId): void { + private function addAgencyUserInstructions(array &$taskInput, array $optionalInputShape, ?string $identityLine, int $sessionId): void { if (!isset($optionalInputShape['user_instructions'])) { return; } + $userInstructions = ''; try { $userInstructions = $this->getSessionSystemPrompt($sessionId); } catch (InternalException $e) { $this->logger->warning('Could not get the user instructions of the session, the agent will run without them', ['exception' => $e, 'sessionId' => $sessionId]); - return; + } + if ($identityLine !== null) { + $userInstructions = $userInstructions === '' + ? $identityLine + : $identityLine . "\n\n" . $userInstructions; } if ($userInstructions !== '') { $taskInput['user_instructions'] = $userInstructions; @@ -863,6 +903,7 @@ private function scheduleAgencyTask( int $sessionId, bool $withUserInstructions = true, ?string $memoriesUserId = null, + ?string $identityLine = null, ): int { $customId = 'chatty-llm:' . $sessionId; $this->checkIfSessionIsThinking($userId, $customId); @@ -877,7 +918,7 @@ private function scheduleAgencyTask( $taskInput['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); } if ($withUserInstructions) { - $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $sessionId); + $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $identityLine, $sessionId); } /** @psalm-suppress UndefinedClass */ $task = new Task( @@ -919,6 +960,7 @@ private function scheduleAgencyMultimodalTask( int $sessionId, array $inputAttachments, ?string $memoriesUserId = null, + ?string $identityLine = null, ): int { $customId = 'chatty-llm:' . $sessionId; $this->checkIfSessionIsThinking($userId, $customId); @@ -931,7 +973,7 @@ private function scheduleAgencyMultimodalTask( $taskInput['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); /** @psalm-suppress UndefinedClass */ $optionalInputShape = $this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\MultimodalContextAgentInteraction::ID]['optionalInputShape'] ?? []; - $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $sessionId); + $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $identityLine, $sessionId); /** @psalm-suppress UndefinedClass */ $task = new Task( \OCP\TaskProcessing\TaskTypes\MultimodalContextAgentInteraction::ID, @@ -1018,6 +1060,7 @@ private function scheduleAgencyAudioTask( int $sessionId, int $queryMessageId, ?string $memoriesUserId = null, + ?string $identityLine = null, ): int { $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; $this->checkIfSessionIsThinking($userId, $customId); @@ -1031,7 +1074,7 @@ private function scheduleAgencyAudioTask( if (isset($optionalInputShape['memories'])) { $taskInput['memories'] = $this->sessionSummaryService->getMemories($memoriesUserId ?? $userId); } - $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $sessionId); + $this->addAgencyUserInstructions($taskInput, $optionalInputShape, $identityLine, $sessionId); /** @psalm-suppress UndefinedClass */ $task = new Task( \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, diff --git a/tests/unit/Service/ChatServiceAgencyTest.php b/tests/unit/Service/ChatServiceAgencyTest.php index 8c6cee194..2de30d367 100644 --- a/tests/unit/Service/ChatServiceAgencyTest.php +++ b/tests/unit/Service/ChatServiceAgencyTest.php @@ -72,10 +72,13 @@ protected function setUp(): void { $this->sessionSummaryService = $this->createMock(SessionSummaryService::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters = []): string => $text); + $this->service = new ChatService( $this->createMock(IUserManager::class), $this->createMock(IAppConfig::class), - $this->createMock(IL10N::class), + $l10n, $this->sessionMapper, $this->messageMapper, $this->createMock(IDBConnection::class), @@ -188,4 +191,43 @@ public function testAgentSessionsScheduleAsTheAgentOwnerWithTheRequesterMemories $this->assertSame($agentUserId, $this->scheduledTask->getUserId()); $this->assertArrayHasKey('memories', $this->scheduledTask->getInput()); } + + /** + * The generation tasks of agent sessions must describe the human requester + * and the agent: without it, the model would not know either identity. + */ + public function testAgentSessionsGetTheIdentityLine(): void { + $session = new Session(); + $session->setUserId('agent_user_id'); + $session->setRequesterUserId(self::USER_ID); + $this->setUserSession($session); + $this->setAgentOptionalInputs(['user_instructions' => 'shape']); + $this->messageMapper->method('getFirstNMessages') + ->with(self::SESSION_ID, 1, Message::ROLE_SYSTEM) + ->willReturn($this->systemMessage(self::USER_INSTRUCTIONS)); + + $this->service->scheduleMessageGeneration('agent_user_id', self::SESSION_ID, 0, 'My Group Room'); + + $this->assertNotNull($this->scheduledTask); + $userInstructions = $this->scheduledTask->getInput()['user_instructions'] ?? null; + $this->assertNotNull($userInstructions); + // the agent identity and the requester identity are both in the line + $this->assertStringContainsString('agent_user_id', $userInstructions); + $this->assertStringContainsString(self::USER_ID, $userInstructions); + $this->assertStringContainsString('My Group Room', $userInstructions); + // the session instructions must still be part of the agent context + $this->assertStringContainsString(self::USER_INSTRUCTIONS, $userInstructions); + } + + public function testHumanSessionsGetNoIdentityLine(): void { + $this->setAgentOptionalInputs(['user_instructions' => 'shape']); + $this->messageMapper->method('getFirstNMessages') + ->with(self::SESSION_ID, 1, Message::ROLE_SYSTEM) + ->willReturn($this->systemMessage(self::USER_INSTRUCTIONS)); + + $this->service->scheduleMessageGeneration(self::USER_ID, self::SESSION_ID); + + $this->assertNotNull($this->scheduledTask); + $this->assertSame(self::USER_INSTRUCTIONS, $this->scheduledTask->getInput()['user_instructions'] ?? null); + } } From ae680e780d1b6f1cbd2da1132f8e043edae1d7f0 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 4 Nov 2026 16:59:43 +0100 Subject: [PATCH 9/9] fix tests Signed-off-by: Julien Veyssier --- tests/unit/Service/ChatServiceAgencyTest.php | 39 ++++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/tests/unit/Service/ChatServiceAgencyTest.php b/tests/unit/Service/ChatServiceAgencyTest.php index 2de30d367..410b172b0 100644 --- a/tests/unit/Service/ChatServiceAgencyTest.php +++ b/tests/unit/Service/ChatServiceAgencyTest.php @@ -43,6 +43,12 @@ class ChatServiceAgencyTest extends TestCase { private IManager&MockObject $taskProcessingManager; private SessionMapper&MockObject $sessionMapper; private SessionSummaryService&MockObject $sessionSummaryService; + private IUserManager&MockObject $userManager; + private IAppConfig&MockObject $appConfig; + private IL10N&MockObject $l10n; + private IDBConnection&MockObject $db; + private LoggerInterface&MockObject $logger; + private ITimeFactory&MockObject $timeFactory; private ?Task $scheduledTask = null; protected function setUp(): void { @@ -73,24 +79,43 @@ protected function setUp(): void { $this->sessionSummaryService = $this->createMock(SessionSummaryService::class); $l10n = $this->createMock(IL10N::class); - $l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters = []): string => $text); + $l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters = []): string => ($parameters !== [] ? vsprintf(str_replace('%n', "\n", $text), $parameters) : $text)); + $this->l10n = $l10n; + $this->userManager = $this->createMock(IUserManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->db = $this->createMock(IDBConnection::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + + $this->rebuildService(); + } + + /** + * The service must be rebuilt when the mocked SessionMapper changes: + * re-stubbing an already configured mock method would not override + * the previously configured return value. + */ + private function rebuildService(): void { $this->service = new ChatService( - $this->createMock(IUserManager::class), - $this->createMock(IAppConfig::class), - $l10n, + $this->userManager, + $this->appConfig, + $this->l10n, $this->sessionMapper, $this->messageMapper, - $this->createMock(IDBConnection::class), + $this->db, $this->sessionSummaryService, $this->taskProcessingManager, - $this->createMock(LoggerInterface::class), - $this->createMock(ITimeFactory::class), + $this->logger, + $this->timeFactory, ); } private function setUserSession(Session $session): void { + $this->sessionMapper = $this->createMock(SessionMapper::class); + $this->sessionMapper->method('exists')->willReturn(true); $this->sessionMapper->method('getUserSession')->willReturn($session); + $this->rebuildService(); } private function setAgentOptionalInputs(array $optionalInputShape): void {