From 0f141da8b61a78e28d03c6a4d0e417ce5f45a85f Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 1/8] Give a channel somewhere to keep a name of its own --- server/drizzle/0026_channel_summary.sql | 3 + server/drizzle/meta/0026_snapshot.json | 3048 +++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/core.ts | 16 + 4 files changed, 3074 insertions(+) create mode 100644 server/drizzle/0026_channel_summary.sql create mode 100644 server/drizzle/meta/0026_snapshot.json diff --git a/server/drizzle/0026_channel_summary.sql b/server/drizzle/0026_channel_summary.sql new file mode 100644 index 000000000..479d90410 --- /dev/null +++ b/server/drizzle/0026_channel_summary.sql @@ -0,0 +1,3 @@ +ALTER TABLE "channels" ADD COLUMN "summary" text;--> statement-breakpoint +ALTER TABLE "channels" ADD COLUMN "summary_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "channels_awaiting_summary_idx" ON "channels" USING btree ("id") WHERE "channels"."summary" is null and "channels"."deleted_at" is null; \ No newline at end of file diff --git a/server/drizzle/meta/0026_snapshot.json b/server/drizzle/meta/0026_snapshot.json new file mode 100644 index 000000000..62db75127 --- /dev/null +++ b/server/drizzle/meta/0026_snapshot.json @@ -0,0 +1,3048 @@ +{ + "id": "fb3704b5-21f5-4d73-a80d-88fad27b6b85", + "prevId": "d96da430-35aa-475c-a060-f55151269d6d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 0a0fb4633..3c1aa00fa 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1787926472382, "tag": "0025_backfill_existing_users_have_onboarded", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1788271863590, + "tag": "0026_channel_summary", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index 44d5753a7..42875503a 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -259,6 +259,10 @@ export const channels = pgTable( onDelete: "set null", }), override: jsonb("override"), + /** A few words about the conversation. Channel grain like `last_message`; null is ordinary. */ + summary: text("summary"), + /** When the summary above was written, so a later change can decide whether to redo it. */ + summaryAt: timestamp("summary_at", { withTimezone: true }), /** * The last thing said in this channel, denormalised so a roster is one indexed read. * @@ -302,6 +306,18 @@ export const channels = pgTable( index("channels_recent_activity_idx").on( sql`COALESCE(${table.lastMessageAt}, ${table.createdAt}) DESC`, ), + /** + * The channels still waiting for a summary. + * + * Partial, on the condition rather than the column, because the sweep that offers this work asks + * for exactly the rows this index holds and nothing else. Every channel that has been summarised + * leaves the index, so it shrinks as the deployment settles rather than growing with it: a + * question asked every couple of seconds on every replica should not be a scan of every + * conversation anybody has ever had. + */ + index("channels_awaiting_summary_idx") + .on(table.id) + .where(sql`${table.summary} is null and ${table.deletedAt} is null`), ], ); From 109b62e163da93954990dcec9a685128623cfaf4 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 2/8] Ask the deployment's own model for a few words, without running a turn --- server/src/channels/text.ts | 12 ++++ server/src/channels/titler.ts | 73 ++++++++++++++++++++++ server/tests/channel-titler.test.ts | 93 +++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 server/src/channels/text.ts create mode 100644 server/src/channels/titler.ts create mode 100644 server/tests/channel-titler.test.ts diff --git a/server/src/channels/text.ts b/server/src/channels/text.ts new file mode 100644 index 000000000..e549816ff --- /dev/null +++ b/server/src/channels/text.ts @@ -0,0 +1,12 @@ +/** + * One line a roster can draw: control characters stripped, whitespace collapsed, cut on code points + * so an emoji is never split. The caller supplies the cap; a preview and a title want different ones. + */ +export function oneLine(text: string, maxCodePoints: number): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point. + const flattened = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); + const collapsed = flattened.replace(/\s+/g, " "); + const codePoints = Array.from(collapsed); + if (codePoints.length <= maxCodePoints) return collapsed; + return `${codePoints.slice(0, maxCodePoints - 1).join("")}…`; +} diff --git a/server/src/channels/titler.ts b/server/src/channels/titler.ts new file mode 100644 index 000000000..6032935a1 --- /dev/null +++ b/server/src/channels/titler.ts @@ -0,0 +1,73 @@ +/** + * One model call for a few words. Not a Bot and not a turn: a title is not said to anybody and is + * not part of the conversation it names, so there is no runtime work and no thread to hold. + */ +import { oneLine } from "./text"; + +/** Where an OpenAI-compatible provider answers, overridable the way `agent-bot` overrides it. */ +const DEFAULT_BASE_URL = "https://api.openai.com/v1"; + +/** Rules, not an example: a model copies an example's subject as readily as its shape. */ +const INSTRUCTION = [ + "You name conversations, like a title in a sidebar.", + "Answer with three to six words naming what the conversation is about.", + "No quotation marks, no trailing period, no prefix such as 'Conversation about'.", + "Name the subject itself, not the fact that somebody asked about it.", +].join(" "); + +export type TitlerOptions = { + /** The model to ask, from the tenant package. */ + model: string; + /** Resolved per call, so a rotated credential is picked up without a restart. */ + resolveApiKey: () => Promise; + baseUrl?: string; + /** Injectable so a test drives this without a network. */ + fetchImpl?: typeof fetch; + /** How long one call may take before it is given up on. */ + timeoutMs?: number; +}; + +/** No key resolves to null and is not retried; a provider error throws, so the queue retries it. */ +export function createChannelTitler(options: TitlerOptions) { + const call = options.fetchImpl ?? fetch; + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + + return async (excerpt: string): Promise => { + const apiKey = await options.resolveApiKey(); + if (!apiKey) return null; + + const response = await call(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: options.model, + messages: [ + { role: "system", content: INSTRUCTION }, + { role: "user", content: excerpt }, + ], + // Reasoning tokens come out of this budget, so a cap sized for the answer alone 400s. + max_completion_tokens: 512, + }), + signal: AbortSignal.timeout(options.timeoutMs ?? 20_000), + }); + + if (!response.ok) { + // Capped: this lands on the work item's row, and an HTML error page would land there whole. + const detail = oneLine(await response.text().catch(() => ""), 200); + throw new Error( + `The model refused to name a conversation: ${response.status} ${detail}`.trim(), + ); + } + + const body = (await response.json()) as { + choices?: { message?: { content?: unknown } }[]; + }; + const content = body.choices?.[0]?.message?.content; + if (typeof content !== "string") return null; + const answer = content.trim(); + return answer.length > 0 ? answer : null; + }; +} diff --git a/server/tests/channel-titler.test.ts b/server/tests/channel-titler.test.ts new file mode 100644 index 000000000..953be3044 --- /dev/null +++ b/server/tests/channel-titler.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { createChannelTitler } from "../src/channels/titler"; + +function respondWith(body: unknown, status = 200) { + const calls: { url: string; body: Record }[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ + url: String(url), + body: JSON.parse(String(init?.body ?? "{}")), + }); + return new Response( + typeof body === "string" ? body : JSON.stringify(body), + { status }, + ); + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +describe("asking the model for a title", () => { + test("sends the excerpt to the deployment's own model", async () => { + const { calls, fetchImpl } = respondWith({ + choices: [{ message: { content: "Travel receipt rules" } }], + }); + + const answer = await createChannelTitler({ + model: "gpt-4.1-mini", + resolveApiKey: async () => "key-123", + fetchImpl, + })("Asked: which receipts count as travel?"); + + expect(answer).toBe("Travel receipt rules"); + expect(calls[0]?.url).toBe("https://api.openai.com/v1/chat/completions"); + expect(calls[0]?.body.model).toBe("gpt-4.1-mini"); + }); + + test("asks nothing at all when the deployment has no key", async () => { + const { calls, fetchImpl } = respondWith({}); + + const answer = await createChannelTitler({ + model: "gpt-4.1-mini", + resolveApiKey: async () => null, + fetchImpl, + })("Asked: anything?"); + + // Not a failure. A deployment with no model configured does not name conversations, and must + // not spend a request finding that out on every pass. + expect(answer).toBeNull(); + expect(calls).toHaveLength(0); + }); + + test("throws when the provider refuses, so the work is tried again", async () => { + const { fetchImpl } = respondWith("rate limited", 429); + + await expect( + createChannelTitler({ + model: "gpt-4.1-mini", + resolveApiKey: async () => "key-123", + fetchImpl, + })("Asked: anything?"), + ).rejects.toThrow("429"); + }); + + test("carries only one line of the provider's complaint", async () => { + const { fetchImpl } = respondWith( + "\n Too many requests\n", + 503, + ); + + // The message ends up on the work item as the reason it was released, so a provider that + // answers with a page of HTML must not put that page in the database. + const thrown = await createChannelTitler({ + model: "gpt-4.1-mini", + resolveApiKey: async () => "key-123", + fetchImpl, + })("Asked: anything?").catch((error: Error) => error.message); + + expect(thrown).not.toContain("\n"); + }); + + test("an empty completion is no answer rather than an empty title", async () => { + const { fetchImpl } = respondWith({ + choices: [{ message: { content: " " } }], + }); + + const answer = await createChannelTitler({ + model: "gpt-4.1-mini", + resolveApiKey: async () => "key-123", + fetchImpl, + })("Asked: anything?"); + + expect(answer).toBeNull(); + }); +}); From 6d1fef5dd07c157fe95764a3509d8568c710453b Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 3/8] Name a conversation on the work queue, without taking its thread lock --- server/src/channels/events.ts | 2 + server/src/channels/summary.ts | 337 +++++++++++++ .../tests/channel-summary.integration.test.ts | 461 ++++++++++++++++++ 3 files changed, 800 insertions(+) create mode 100644 server/src/channels/summary.ts create mode 100644 server/tests/channel-summary.integration.test.ts diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts index 586efa701..ebdd59665 100644 --- a/server/src/channels/events.ts +++ b/server/src/channels/events.ts @@ -26,6 +26,8 @@ export type ChannelActivityEvent = { lastMessage: string | null; lastMessageAt: string | null; lastMessageAgentId: string | null; + /** Newly written summary, on its own event: the sweep runs seconds after the activity it follows. */ + summary?: string; /** The channel is hidden from every member's roster. Absent on an ordinary activity event. */ deleted?: true; /** diff --git a/server/src/channels/summary.ts b/server/src/channels/summary.ts new file mode 100644 index 000000000..81fc92152 --- /dev/null +++ b/server/src/channels/summary.ts @@ -0,0 +1,337 @@ +/** + * A few words saying what a conversation is about, since a channel's name is only its Bots' names. + * + * Not a headless turn: `run-turn.ts` takes the thread lock, which would refuse the person's own next + * message with 409 for the lock's TTL. `getThreadMessages` takes no lock, so this reads and asks. + * + * Offer then claim, like `work/culler.ts`. The offer is derived from the table rather than from an + * event, so a missed sweep costs two seconds where a missed event would cost the name entirely. + */ +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { + channelMemberships, + channels, + intelligenceChannelMappings, +} from "../db/schema"; +import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue"; +import { CHANNEL_ACTIVITY_TOPIC, type ChannelActivityEvent } from "./events"; +import { oneLine } from "./text"; + +export const CHANNEL_SUMMARY_KIND = "channel.summary"; + +/** A title long enough to truncate says no more than the preview it replaced. */ +const MAX_SUMMARY_CODE_POINTS = 60; + +/** How much of the opening exchange the model is shown. Enough to see the topic, not the whole run. */ +const MAX_EXCERPT_CODE_POINTS = 600; + +/** A seam, so a test drives every path with no key and no network. Null means nothing worth writing. */ +export type ChannelTitler = (excerpt: string) => Promise; + +/** The one method of the Intelligence client this file uses. Narrowed for the same reason. */ +export type ThreadTranscript = { + getThreadMessages(params: { threadId: string; userId: string }): Promise<{ + messages: { role: string; content?: unknown }[]; + }>; +}; + +export type ChannelSummaryOptions = { + database: Database; + queue: WorkQueue; + transcript: ThreadTranscript; + title: ChannelTitler; + /** Who this replica is, for the lease. */ + owner: string; + leaseMs?: number; + maxAttempts?: number; + /** How many channels one pass will offer, and how many it will claim. */ + limit?: number; +}; + +export type SummaryReport = { + considered: number; + written: string[]; + skipped: { channelId: string; reason: string }[]; +}; + +/** + * Offer every conversation that has been spoken in and has no name yet. + * + * `channels_awaiting_summary_idx` keeps this off a full scan; `offer` is idempotent on (kind, key), + * so every replica and every later pass collapse onto one row. + */ +export async function offerChannelsAwaitingSummary( + options: Pick, +): Promise<{ offered: string[] }> { + const rows = await options.database + .select({ id: channels.id }) + .from(channels) + .where( + and( + isNull(channels.summary), + isNull(channels.deletedAt), + sql`${channels.lastMessageAt} is not null`, + ), + ) + .limit(options.limit ?? 20); + + const offered: string[] = []; + for (const row of rows) { + await options.queue.offer({ + kind: CHANNEL_SUMMARY_KIND, + key: row.id, + payload: { channelId: row.id }, + }); + offered.push(row.id); + } + return { offered }; +} + +/** Name what this replica can claim. The lease is renewed per item: a model call is not instant. */ +export async function summariseClaimedChannels( + options: ChannelSummaryOptions, +): Promise { + const leaseMs = options.leaseMs ?? 60_000; + const claimed = await options.queue.claim({ + kind: CHANNEL_SUMMARY_KIND, + owner: options.owner, + leaseMs, + limit: options.limit ?? 5, + ...(options.maxAttempts === undefined + ? {} + : { maxAttempts: options.maxAttempts }), + }); + + const report: SummaryReport = { + considered: claimed.length, + written: [], + skipped: [], + }; + + for (const item of claimed) { + const channelId = String(item.payload.channelId ?? item.key); + if ( + !(await options.queue.renew({ + kind: CHANNEL_SUMMARY_KIND, + key: item.key, + owner: options.owner, + leaseMs, + })) + ) { + report.skipped.push({ + channelId, + reason: "the lease went to another replica", + }); + continue; + } + + try { + const attempt = await summariseOne(options, channelId); + if (attempt === "not yet") { + // Put back, not finished: Intelligence has not persisted the message yet. + await options.queue.release({ + kind: CHANNEL_SUMMARY_KIND, + key: item.key, + owner: options.owner, + delayMs: 15_000, + reason: "the conversation is not readable yet", + }); + report.skipped.push({ channelId, reason: "not readable yet" }); + continue; + } + await options.queue.finish({ + kind: CHANNEL_SUMMARY_KIND, + key: item.key, + owner: options.owner, + }); + if (attempt === "written") report.written.push(channelId); + else report.skipped.push({ channelId, reason: attempt }); + } catch (error) { + // Released and pushed out: a model that just refused will refuse again, and nothing is broken meanwhile. + const reason = + error instanceof Error ? error.message : "could not be summarised"; + await options.queue.release({ + kind: CHANNEL_SUMMARY_KIND, + key: item.key, + owner: options.owner, + delayMs: 60_000, + reason, + }); + // Said out loud when it gives up, because at the cap this loop simply never sees the channel + // again and every pass afterwards looks clean. + if (item.attempts >= (options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)) { + console.warn( + JSON.stringify({ + type: "channel-summary-gave-up", + channelId, + attempts: item.attempts, + reason, + }), + ); + } + report.skipped.push({ channelId, reason }); + } + } + + return report; +} + +/** + * Three words, not a boolean, so `"not yet"` is told apart from the rest: a conversation offered the + * instant somebody speaks may have no message in Intelligence yet, and finishing it never names it. + */ +type Attempt = "written" | "not yet" | "nothing to name it with"; + +async function summariseOne( + options: ChannelSummaryOptions, + channelId: string, +): Promise { + /* A queue key is not a foreign key, so work outlives its channel. Gone is final, not not-yet. */ + const [existing] = await options.database + .select({ deletedAt: channels.deletedAt }) + .from(channels) + .where(eq(channels.id, channelId)); + if (!existing || existing.deletedAt !== null) + return "nothing to name it with"; + + /* Threads are per person, so the one read must be chosen: the earliest member opened it. */ + const [owner] = await options.database + .select({ + userId: channelMemberships.userId, + threadId: intelligenceChannelMappings.threadId, + }) + .from(channelMemberships) + .innerJoin( + intelligenceChannelMappings, + and( + eq(intelligenceChannelMappings.channelId, channelMemberships.channelId), + eq(intelligenceChannelMappings.userId, channelMemberships.userId), + ), + ) + .where(eq(channelMemberships.channelId, channelId)) + .orderBy(asc(channelMemberships.createdAt), asc(channelMemberships.userId)) + .limit(1); + + // No member holds a thread for this channel yet, so there is nothing to read — yet. + if (!owner) return "not yet"; + + const excerpt = await openingOf(options.transcript, owner); + if (!excerpt) return "not yet"; + + // Final, unlike the two above: no key or nothing usable does not change a minute later. + const answer = await options.title(excerpt); + if (!answer) return "nothing to name it with"; + + const title = oneLine(stripWrappingQuotes(answer), MAX_SUMMARY_CODE_POINTS); + if (!title) return "nothing to name it with"; + + return await options.database.transaction( + async (transaction) => { + // Conditional, not check-then-write: a second replica changes no rows and announces nothing. + const applied = await transaction + .update(channels) + .set({ summary: title, summaryAt: new Date(), updatedAt: new Date() }) + .where(and(eq(channels.id, channelId), isNull(channels.summary))) + .returning({ id: channels.id }); + // Somebody else named it between the claim and this write. Their title stands, and this work + // is done rather than still owed. + if (applied.length === 0) return "written"; + + const members = await transaction + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, channelId)); + + // Announced inside the transaction, so it is delivered on commit and a write that rolls back + // is never announced — the rule every other writer in this area follows. + const event: ChannelActivityEvent = { + channelId, + memberIds: members.map((member) => member.userId), + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + summary: title, + }; + await transaction.execute( + sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`, + ); + return "written"; + }, + { isolationLevel: "read committed" }, + ); +} + +/* The opening exchange only: a conversation that wandered is still filed under what it opened for. */ +async function openingOf( + transcript: ThreadTranscript, + owner: { threadId: string; userId: string }, +): Promise { + const history = await transcript.getThreadMessages({ + threadId: owner.threadId, + userId: owner.userId, + }); + const question = history.messages.find((message) => message.role === "user"); + if (!question) return null; + const answer = history.messages.find( + (message) => message.role === "assistant", + ); + + const asked = textOf(question.content); + if (!asked) return null; + const replied = answer ? textOf(answer.content) : ""; + + return oneLine( + replied ? `Asked: ${asked}\nAnswered: ${replied}` : `Asked: ${asked}`, + MAX_EXCERPT_CODE_POINTS, + ); +} + +/* `content` is a string or an array of parts; anything else is no text, not `[object Object]`. */ +function textOf(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + return content + .map((part) => + part && typeof part === "object" && "text" in part + ? String((part as { text?: unknown }).text ?? "") + : "", + ) + .join(" ") + .trim(); +} + +/* Only a matched pair wrapping the whole answer: a title containing a quote keeps it. */ +function stripWrappingQuotes(text: string): string { + const trimmed = text.trim(); + const pairs: [string, string][] = [ + ['"', '"'], + ["'", "'"], + ["\u201c", "\u201d"], + ["\u00ab", "\u00bb"], + ]; + for (const [open, close] of pairs) { + if ( + trimmed.length > 1 && + trimmed.startsWith(open) && + trimmed.endsWith(close) + ) { + return trimmed.slice(1, -1).trim(); + } + } + return trimmed; +} + +/* Finished rows kept an hour, given-up rows a day. That day IS the backoff before a fresh offer. */ +export async function forgetSettledSummaries( + options: Pick, +): Promise { + return await options.queue.purge({ + kind: CHANNEL_SUMMARY_KIND, + olderThanMs: 24 * 60 * 60 * 1_000, + finishedOlderThanMs: 60 * 60 * 1_000, + ...(options.maxAttempts === undefined + ? {} + : { maxAttempts: options.maxAttempts }), + }); +} diff --git a/server/tests/channel-summary.integration.test.ts b/server/tests/channel-summary.integration.test.ts new file mode 100644 index 000000000..b71e15430 --- /dev/null +++ b/server/tests/channel-summary.integration.test.ts @@ -0,0 +1,461 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; +import { createChannelStore } from "../src/channels/routes"; +import { + CHANNEL_SUMMARY_KIND, + type ChannelTitler, + offerChannelsAwaitingSummary, + summariseClaimedChannels, + type ThreadTranscript, +} from "../src/channels/summary"; +import { createThreadIdentity } from "../src/channels/thread-identity"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + channelMemberships, + channels, + intelligenceChannelMappings, + users, + workItems, +} from "../src/db/schema"; +import { createWorkQueue } from "../src/work/queue"; +import { TEST_POOL } from "./support/database"; + +const databaseUrl = + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot"; +const database = createDatabase(databaseUrl, TEST_POOL); +const profileStore = createAgentProfileStore( + database, + new URL("https://managed.example.test/ag-ui"), +); +const store = createChannelStore( + database, + profileStore, + createThreadIdentity("test-deployment"), +); +const queue = createWorkQueue(database); + +const testPrefix = `channel-summary-${randomUUID()}`; +const createdUserIds: string[] = []; +const createdAgentIds: string[] = []; +const createdChannelIds: string[] = []; + +afterEach(async () => { + for (const channelId of createdChannelIds.splice(0)) { + await database.delete(workItems).where(eq(workItems.key, channelId)); + await database + .delete(intelligenceChannelMappings) + .where(eq(intelligenceChannelMappings.channelId, channelId)); + await database.delete(channels).where(eq(channels.id, channelId)); + } + for (const agentId of createdAgentIds.splice(0)) { + await database + .delete(agentProfiles) + .where(eq(agentProfiles.agentId, agentId)); + await database.delete(agents).where(eq(agents.id, agentId)); + } + for (const userId of createdUserIds.splice(0)) { + await database.delete(users).where(eq(users.id, userId)); + } +}); + +afterAll(async () => { + await database.$client.close(); +}); + +async function createUser(): Promise { + const id = `${testPrefix}-user-${randomUUID()}`; + await database.insert(users).values({ + id, + email: `${id}@example.test`, + name: "Channel Summary Test User", + }); + createdUserIds.push(id); + return { id, role: "user" }; +} + +async function createAgent(owner: AgentActor) { + const profile = await profileStore.create(owner, { + name: "Expense Manager", + title: "Finance Operations", + roleDescription: "Review receipts.", + visibility: "private", + }); + createdAgentIds.push(profile.id); + return profile.id; +} + +/** A channel somebody has actually said something in, which is the only kind worth naming. */ +async function createUsedChannel(owner: AgentActor) { + const agentId = await createAgent(owner); + const channel = await store.create(owner, [agentId]); + createdChannelIds.push(channel.id); + await store.recordActivity(owner, channel.id, { + text: "Which of these receipts count as travel?", + agentId: null, + at: new Date(), + }); + return channel; +} + +/** A transcript with one question and one answer in it, which is what a title is made from. */ +function transcriptOf(question: string, answer?: string): ThreadTranscript { + return { + getThreadMessages: async () => ({ + messages: [ + { role: "user", content: question }, + ...(answer ? [{ role: "assistant", content: answer }] : []), + ], + }), + }; +} + +function titler(answer: string | null): ChannelTitler { + return async () => answer; +} + +async function summaryOf(channelId: string) { + const [row] = await database + .select({ summary: channels.summary, summaryAt: channels.summaryAt }) + .from(channels) + .where(eq(channels.id, channelId)); + return row; +} + +/** + * Queue one conversation, by name. + * + * Deliberately not `offerChannelsAwaitingSummary`, which offers every unnamed conversation in the + * database. A claim takes whatever is queued, so a test that offers the whole table and then claims + * will happily write a fabricated title onto somebody's real conversation when the suite is pointed + * at a database that has any — a development one, for instance. Tests that exercise the offer query + * itself assert on what it returns and never claim. + */ +async function offer(channelId: string) { + await queue.offer({ kind: CHANNEL_SUMMARY_KIND, key: channelId }); +} + +const options = (overrides: { + transcript?: ThreadTranscript; + title?: ChannelTitler; + owner?: string; +}) => ({ + database, + queue, + transcript: overrides.transcript ?? transcriptOf("Which receipts?"), + title: overrides.title ?? titler("Travel receipt rules"), + owner: overrides.owner ?? `test-${randomUUID()}`, +}); + +describe("offering conversations to be named", () => { + test("offers one that has been used and has no name yet", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + + const { offered } = await offerChannelsAwaitingSummary({ + database, + queue, + limit: 200, + }); + + expect(offered).toContain(channel.id); + }); + + test("does not offer a channel nobody has said anything in", async () => { + const owner = await createUser(); + const agentId = await createAgent(owner); + const channel = await store.create(owner, [agentId]); + createdChannelIds.push(channel.id); + + const { offered } = await offerChannelsAwaitingSummary({ + database, + queue, + limit: 200, + }); + + // There is nothing for a model to read, and asking it to name an empty thread spends money to + // produce a guess. + expect(offered).not.toContain(channel.id); + }); + + test("offering the same conversation twice leaves one piece of work", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + + await offerChannelsAwaitingSummary({ database, queue, limit: 200 }); + await offerChannelsAwaitingSummary({ database, queue, limit: 200 }); + + const rows = await database + .select({ key: workItems.key }) + .from(workItems) + .where(eq(workItems.key, channel.id)); + expect(rows).toHaveLength(1); + }); +}); + +/** + * What the first pass on a deployment with a history looks like. + * + * Every conversation ever used and never named is eligible at once, so the only thing standing + * between a deploy and a model request per historical conversation is the claim limit. These pin + * that limit as behaviour rather than leaving it a constant somebody can raise without noticing what + * it is holding back. + */ +describe("a backlog of unnamed conversations", () => { + test("names at most one batch per pass, not one per conversation", async () => { + const owner = await createUser(); + const agentId = await createAgent(owner); + const made: string[] = []; + for (let index = 0; index < 6; index += 1) { + const channel = await store.create(owner, [agentId]); + createdChannelIds.push(channel.id); + await store.recordActivity(owner, channel.id, { + text: `Question ${index}`, + agentId: null, + at: new Date(), + }); + made.push(channel.id); + } + for (const id of made) await offer(id); + + let calls = 0; + const counting = options({ + title: async () => { + calls += 1; + return "A title"; + }, + }); + + const first = await summariseClaimedChannels({ ...counting, limit: 2 }); + expect(first.written).toHaveLength(2); + expect(calls).toBe(2); + + const second = await summariseClaimedChannels({ ...counting, limit: 2 }); + expect(second.written).toHaveLength(2); + // The next batch, not the first one again. + expect(second.written.some((id) => first.written.includes(id))).toBe(false); + expect(calls).toBe(4); + }); +}); + +describe("naming a claimed conversation", () => { + test("writes the title and stamps when it was written", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + const report = await summariseClaimedChannels( + options({ + transcript: transcriptOf( + "Which of these receipts count as travel?", + "The three flights and the hotel do.", + ), + title: titler("Travel receipt rules"), + }), + ); + + expect(report.written).toContain(channel.id); + const row = await summaryOf(channel.id); + expect(row?.summary).toBe("Travel receipt rules"); + expect(row?.summaryAt).toBeInstanceOf(Date); + }); + + test("two replicas racing for the same conversation name it once", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + /* + * Overlapping, not sequential. Two sweeps run one after the other pass just as happily with the + * row-level locking removed, so they prove the conditional update and nothing about the claim. + * These two are in flight together on separate pooled connections, which is what `for update + * skip locked` is there for: the second finds nothing to take rather than waiting behind the + * first and then doing the work twice. + */ + const [left, right] = await Promise.all([ + summariseClaimedChannels(options({ title: titler("Left title") })), + summariseClaimedChannels(options({ title: titler("Right title") })), + ]); + + expect([...left.written, ...right.written]).toEqual([channel.id]); + expect(left.considered + right.considered).toBe(1); + const written = (await summaryOf(channel.id))?.summary; + expect(["Left title", "Right title"]).toContain(written); + }); + + test("never overwrites a name the conversation already has", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await database + .update(channels) + .set({ summary: "Named already", summaryAt: new Date() }) + .where(eq(channels.id, channel.id)); + // Offered before it was named, which is exactly the race two replicas produce. + await queue.offer({ kind: CHANNEL_SUMMARY_KIND, key: channel.id }); + + await summariseClaimedChannels( + options({ title: titler("A second opinion") }), + ); + + expect((await summaryOf(channel.id))?.summary).toBe("Named already"); + }); + + test("leaves the conversation unnamed when the model has no answer", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + const report = await summariseClaimedChannels( + options({ title: titler(null) }), + ); + + // A deployment with no model key reaches this on every pass. Nothing is written, nothing is + // broken, and the roster keeps drawing the Bot's name. + expect(report.written).not.toContain(channel.id); + expect((await summaryOf(channel.id))?.summary).toBeNull(); + }); + + test("comes back later when the conversation is not readable yet", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + // What a conversation looks like in the seconds between somebody sending a message and + // Intelligence holding it. Finishing the work here is how an ordinary conversation ends up + // never named at all. + const report = await summariseClaimedChannels( + options({ + transcript: { getThreadMessages: async () => ({ messages: [] }) }, + }), + ); + + expect(report.skipped).toContainEqual({ + channelId: channel.id, + reason: "not readable yet", + }); + const [item] = await database + .select({ finishedAt: workItems.finishedAt, runAt: workItems.runAt }) + .from(workItems) + .where(eq(workItems.key, channel.id)); + expect(item?.finishedAt).toBeNull(); + expect(item?.runAt.getTime()).toBeGreaterThan(Date.now()); + }); + + test("a conversation that no longer exists is dropped, not retried", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + // The channel goes while its naming is still queued. A queue key is not a foreign key, so the + // work outlives the thing it was about — which happens for real on a delete, and happens on + // every integration test that seeds a channel and tears it down. + await database.delete(channels).where(eq(channels.id, channel.id)); + + await summariseClaimedChannels(options({})); + + const [item] = await database + .select({ + finishedAt: workItems.finishedAt, + attempts: workItems.attempts, + }) + .from(workItems) + .where(eq(workItems.key, channel.id)); + // Finished rather than released: nothing about a deleted conversation is different in fifteen + // seconds, and retrying it to the attempt cap burns a model call's worth of work each time. + expect(item?.finishedAt).not.toBeNull(); + expect(item?.attempts).toBe(1); + }); + + test("a soft-deleted conversation is dropped the same way", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + await database + .update(channels) + .set({ deletedAt: new Date() }) + .where(eq(channels.id, channel.id)); + + await summariseClaimedChannels(options({})); + + const [item] = await database + .select({ finishedAt: workItems.finishedAt }) + .from(workItems) + .where(eq(workItems.key, channel.id)); + expect(item?.finishedAt).not.toBeNull(); + expect((await summaryOf(channel.id))?.summary).toBeNull(); + }); + + test("stays inside the NOTIFY payload limit on a crowded channel", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + // The title is short and fixed; the term that grows is the member list the payload carries, so + // that is the term the test grows. `pg_notify` refuses a payload over 8000 bytes outright, and + // it refuses it inside the transaction that writes the title. + const crowd = []; + for (let index = 0; index < 40; index += 1) { + const member = await createUser(); + crowd.push(member.id); + await database + .insert(channelMemberships) + .values({ channelId: channel.id, userId: member.id }); + } + + await offer(channel.id); + const report = await summariseClaimedChannels( + options({ title: titler("Travel receipt rules") }), + ); + + expect(report.written).toContain(channel.id); + // Recorded rather than merely survived: this is the number that says how much room is left, and + // it is what a later change adding a field to the event has to be measured against. + const payload = JSON.stringify({ + channelId: channel.id, + memberIds: [owner.id, ...crowd], + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + summary: "Travel receipt rules", + }); + expect(payload.length).toBeLessThan(8000); + }); + + test("a model that fails leaves the work to be tried again", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + await summariseClaimedChannels( + options({ + title: async () => { + throw new Error("the model is unreachable"); + }, + }), + ); + + expect((await summaryOf(channel.id))?.summary).toBeNull(); + const [item] = await database + .select({ attempts: workItems.attempts, error: workItems.lastError }) + .from(workItems) + .where(eq(workItems.key, channel.id)); + expect(item?.attempts).toBe(1); + expect(item?.error).toContain("the model is unreachable"); + }); + + test("flattens and shortens whatever the model answers with", async () => { + const owner = await createUser(); + const channel = await createUsedChannel(owner); + await offer(channel.id); + + await summariseClaimedChannels( + options({ title: titler(' "Travel\nreceipt rules" ') }), + ); + + // The quotes a model wraps a title in, the newline, and the run of spaces all go: a roster row + // is one line of plain text. + expect((await summaryOf(channel.id))?.summary).toBe("Travel receipt rules"); + }); +}); From 80d5988361396f21f510e05a65423bb76cd7d5bb Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 4/8] Run the naming sweep on every replica, and serve the name with the roster --- server/src/channels/routes.ts | 21 ++++------ server/src/index.ts | 41 +++++++++++++++++++ .../channel-activity.integration.test.ts | 29 +++++++++++++ 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 58c3864e9..808177cb7 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -33,6 +33,7 @@ import { type ChannelEventHub, } from "./events"; import { upgradeWebSocket } from "./socket"; +import { oneLine } from "./text"; import type { ThreadIdentity } from "./thread-identity"; export type AgentChannel = { @@ -45,6 +46,8 @@ export type AgentChannel = { /** A channel plus the last thing said in it, which is what a roster renders. */ export type ChannelSummary = AgentChannel & { + /** A few words about the conversation, or null. Readers fall back to the channel's name. */ + summary: string | null; lastMessage: string | null; lastMessageAt: Date | null; lastMessageAgentId: string | null; @@ -211,20 +214,9 @@ const PRIVATE_AGENT_CHANNEL_DESCRIPTION = "Private agent channel."; const MAX_CHANNEL_NAME_CODE_POINTS = 120; const MAX_ACTIVITY_CODE_POINTS = 200; -/** - * Reduce a message to one line of plain text. - * - * A preview is rendered as text wherever a roster appears, so control characters have nothing to do - * there: at best they are invisible, at worst a terminal escape somebody put in a message follows it - * into a log. Newlines collapse to spaces because a preview is one line by definition. - */ +/** Reduce a message to the one line a roster draws. See `oneLine` for why it is shared. */ function previewOf(text: string) { - // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point. - const flattened = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); - const collapsed = flattened.replace(/\s+/g, " "); - const codePoints = Array.from(collapsed); - if (codePoints.length <= MAX_ACTIVITY_CODE_POINTS) return collapsed; - return `${codePoints.slice(0, MAX_ACTIVITY_CODE_POINTS - 1).join("")}…`; + return oneLine(text, MAX_ACTIVITY_CODE_POINTS); } function channelName(names: string[]) { @@ -479,6 +471,7 @@ export function createChannelStore( agentId: channelAgents.agentId, threadId: intelligenceChannelMappings.threadId, deletedAt: agentProfiles.deletedAt, + channelSummary: channels.summary, lastMessage: channels.lastMessage, lastMessageAt: channels.lastMessageAt, lastMessageAgentId: channels.lastMessageAgentId, @@ -537,6 +530,7 @@ export function createChannelStore( agentIds: [row.agentId], threadId: row.threadId, active: row.deletedAt === null, + summary: row.channelSummary, lastMessage: row.lastMessage, lastMessageAt: row.lastMessageAt, lastMessageAgentId: row.lastMessageAgentId, @@ -1169,6 +1163,7 @@ function channelDto(channel: AgentChannel): AgentChannel { function channelSummaryDto(channel: ChannelSummary) { return { ...channelDto(channel), + summary: channel.summary, lastMessage: channel.lastMessage, // Serialised as ISO-8601 so the browser gets a string it can sort and format. lastMessageAt: channel.lastMessageAt?.toISOString() ?? null, diff --git a/server/src/index.ts b/server/src/index.ts index 6828cb9a9..b2b3bb4eb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,7 +31,13 @@ import { import { createChannelStore } from "./channels/routes"; import { websocket as channelSocket } from "./channels/socket"; import { createStallGuard } from "./channels/stall-guard"; +import { + forgetSettledSummaries, + offerChannelsAwaitingSummary, + summariseClaimedChannels, +} from "./channels/summary"; import { createThreadIdentity } from "./channels/thread-identity"; +import { createChannelTitler } from "./channels/titler"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; import { createComputerGateway } from "./computer/gateway"; @@ -1031,6 +1037,41 @@ repeatAfterEach( 60 * 60 * 1_000, ); +/* + * Naming conversations, in the API process rather than `worker/`, which the single-image container + * does not run. Its own loop, so a slow model never delays a hop. + */ +const channelSummaries = { + database, + queue: createWorkQueue(database), + transcript: routineIntelligence, + title: createChannelTitler({ + model: tenantPackage.model.defaultModel, + resolveApiKey: resolveRuntimeModelApiKey, + }), + owner: `summariser/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`, +}; +repeatAfterEach(async () => { + try { + await offerChannelsAwaitingSummary(channelSummaries); + const report = await summariseClaimedChannels(channelSummaries); + if (report.written.length > 0) { + console.info( + JSON.stringify({ type: "channel-summaries", written: report.written }), + ); + } + // Same pass: one statement, deletes by age, and two replicas running it changes nothing. + await forgetSettledSummaries(channelSummaries); + } catch (error) { + // Never fatal, and never loud enough to drown the log: a deployment with no model configured + // reaches this on every pass, and it has not gone wrong, it simply has no titles. + console.warn( + "[channels] conversations could not be named:", + error instanceof Error ? error.message : error, + ); + } +}, 10_000); + const app = createApp( config, auth, diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts index f8c857be7..610c2bbd6 100644 --- a/server/tests/channel-activity.integration.test.ts +++ b/server/tests/channel-activity.integration.test.ts @@ -223,6 +223,8 @@ describe("channel activity", () => { expect((await store.list(owner)).channels).toEqual([ { ...channel, + // Nothing has named this conversation: the sweep that does runs outside this store. + summary: null, lastMessage: "Categorized three expenses.", lastMessageAgentId: agentId, lastMessageAt: at, @@ -346,6 +348,33 @@ describe("channel activity", () => { (await store.list(owner)).channels.map((channel) => channel.id), ).toEqual([busy.id, quiet.id]); }); + + test("a title rides along on the roster without touching its order", async () => { + const owner = await createUser(); + const agentId = await createAgent(owner); + const quiet = await createChannel(owner, [agentId]); + const busy = await createChannel(owner, [agentId]); + + await store.recordActivity(owner, busy.id, { + agentId, + at: new Date(), + text: "Said something.", + }); + // Named the older, quieter conversation, which is the case that would expose a summary leaking + // into the ordering: it sorts second before and must still sort second after. + await database + .update(channels) + .set({ summary: "An older subject", summaryAt: new Date() }) + .where(eq(channels.id, quiet.id)); + + const roster = (await store.list(owner)).channels; + + expect(roster.map((channel) => channel.id)).toEqual([busy.id, quiet.id]); + expect(roster.map((channel) => channel.summary)).toEqual([ + null, + "An older subject", + ]); + }); }); /** From 6d6818d0e3d154fd52ae7b75dfdcbcedde8b9077 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 5/8] Patch a name onto the row it belongs to, without moving it --- app/src/lib/channels/queries.ts | 4 +- app/src/lib/channels/use-channel-events.ts | 12 +++++ app/tests/channel-event-patch.test.ts | 62 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index 4a62ce053..3f9c97248 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -16,8 +16,10 @@ export type AgentChannel = { active: boolean; }; -/** A channel plus the last thing said in it, which is what the roster renders. */ +/** A channel plus what the roster renders about it. */ export type ChannelSummary = AgentChannel & { + /** A few words about the conversation, or null. The roster falls back to `name`. */ + summary: string | null; lastMessage: string | null; /** ISO-8601, or null for a channel nobody has used yet. */ lastMessageAt: string | null; diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 87a87c41e..51958ebb8 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -31,6 +31,8 @@ export type ChannelActivityEvent = { lastMessage: string | null; lastMessageAt: string | null; lastMessageAgentId: string | null; + /** The channel's newly written summary. Absent on an ordinary activity event. */ + summary?: string; /** The channel is gone from every member's roster. Absent on an ordinary activity event. */ deleted?: true; /** @@ -95,6 +97,16 @@ export function applyChannelEvent( const previous = page.channels[index]; if (!previous) return data; + /* One field, and no re-sort: naming a conversation is not something anybody said in it. */ + if (activity.summary !== undefined) { + if (previous.summary === activity.summary) return data; + const channels = page.channels.slice(); + channels[index] = { ...previous, summary: activity.summary }; + const pages = data.pages.slice(); + pages[holdingPage] = { ...page, channels }; + return { ...data, pages }; + } + /* * A pin patches the one field it is about. * diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts index 6f073ea6e..7dba1dbef 100644 --- a/app/tests/channel-event-patch.test.ts +++ b/app/tests/channel-event-patch.test.ts @@ -222,3 +222,65 @@ describe("a busy signal", () => { ); }); }); + +/** + * A conversation the server has just named. + * + * Written by a sweep some seconds after the message that prompted it, so it arrives on its own long + * after that message was announced. + */ +describe("a summary", () => { + test("patches only the summary, leaving the last message alone", () => { + const data = cache([ + channel("a", { + lastMessage: "Said something.", + lastMessageAt: "2024-04-01T00:00:00.000Z", + lastMessageAgentId: "agent-1", + }), + ]); + + const patched = applyChannelEvent( + data, + event({ channelId: "a", summary: "Expense categories" }), + ); + + expect(patched).not.toBe("unknown"); + if (patched === "unknown") return; + expect(patched.pages[0]?.channels[0]).toEqual({ + ...(data.pages[0]?.channels[0] as ChannelSummary), + summary: "Expense categories", + }); + }); + + test("does not move the row it names", () => { + // Naming a conversation is not something anybody said in it. A row that jumped to the top + // seconds after the message that put it there would read as a second message arriving. + const data = cache([ + channel("recent", { lastMessageAt: "2024-04-02T00:00:00.000Z" }), + channel("older", { lastMessageAt: "2024-04-01T00:00:00.000Z" }), + ]); + + const patched = applyChannelEvent( + data, + event({ channelId: "older", summary: "Something older" }), + ); + + expect(patched).not.toBe("unknown"); + if (patched === "unknown") return; + expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual([ + "recent", + "older", + ]); + }); + + test("returns the same cache when the row already says so", () => { + const data = cache([channel("a", { summary: "Expense categories" })]); + + expect( + applyChannelEvent( + data, + event({ channelId: "a", summary: "Expense categories" }), + ), + ).toBe(data); + }); +}); From ecf483eb5f406fe28a038341eb6bf624b912e246 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 6/8] Type a name in when it arrives, and never on a row that already had one --- app/src/lib/typed-reveal.ts | 68 ++++++++++++++++++++++++++++++++++ app/tests/typed-reveal.test.ts | 34 +++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 app/src/lib/typed-reveal.ts create mode 100644 app/tests/typed-reveal.test.ts diff --git a/app/src/lib/typed-reveal.ts b/app/src/lib/typed-reveal.ts new file mode 100644 index 000000000..a7b33400b --- /dev/null +++ b/app/src/lib/typed-reveal.ts @@ -0,0 +1,68 @@ +import { useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +/** + * Types a conversation's name in as it arrives, so the line reads as learning something rather than + * as a glitch. Only on arrival: otherwise opening the app sets the whole roster typing at once. + */ + +/** Per character, not per title, so every length types at the same speed. Capped so none outstays. */ +export const TYPING_SECONDS_PER_CHARACTER = 0.014; +export const TYPING_MAX_SECONDS = 0.45; + +export function typingSeconds(length: number): number { + return Math.min(length * TYPING_SECONDS_PER_CHARACTER, TYPING_MAX_SECONDS); +} + +/** Naming is none-to-some. A name replacing another name is not this, and must not replay. */ +export function isNaming( + previous: string | undefined, + next: string | undefined, +): boolean { + return !previous && Boolean(next); +} + +export type TypedReveal = { + /** What to draw right now: the whole name, or as much of it as has landed. */ + text: string | undefined; + /** Whether the characters are still arriving, so the caller can show a cursor. */ + typing: boolean; +}; + +export function useTypedReveal(summary: string | undefined): TypedReveal { + const reduce = useReducedMotion(); + const previous = useRef(summary); + const firstRun = useRef(true); + const [typed, setTyped] = useState(null); + + useEffect(() => { + const from = previous.current; + previous.current = summary; + // A row that arrives already named is not a naming. + const mounting = firstRun.current; + firstRun.current = false; + if (mounting || !summary || !isNaming(from, summary)) return; + // The gentler version of a reveal made entirely of movement is no movement. + if (reduce) return; + + let frame = 0; + const started = performance.now(); + const total = typingSeconds(summary.length) * 1000; + // Linear, because this is constant motion. An eased typewriter reads as a machine hesitating. + const step = (now: number) => { + const progress = Math.min((now - started) / total, 1); + setTyped(Math.round(progress * summary.length)); + if (progress < 1) { + frame = requestAnimationFrame(step); + return; + } + // Null, not the full length, so later renders draw the string rather than re-slicing it. + setTyped(null); + }; + frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); + }, [summary, reduce]); + + if (typed === null || !summary) return { text: summary, typing: false }; + return { text: summary.slice(0, typed), typing: true }; +} diff --git a/app/tests/typed-reveal.test.ts b/app/tests/typed-reveal.test.ts new file mode 100644 index 000000000..127333a26 --- /dev/null +++ b/app/tests/typed-reveal.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { + isNaming, + TYPING_MAX_SECONDS, + typingSeconds, +} from "../src/lib/typed-reveal"; + +test("a line that gains a name is a naming", () => { + expect(isNaming(undefined, "Travel receipt rules")).toBe(true); +}); + +test("a row that arrives already named is not", () => { + // Every row on every load takes this path. Treating it as an arrival is what would set the whole + // roster typing at once when somebody opens the app. + expect(isNaming("Travel receipt rules", "Travel receipt rules")).toBe(false); +}); + +test("a name replaced by another name is not an arrival either", () => { + // Nothing does this today. If something ever does, replaying the arrival would misdescribe it. + expect(isNaming("An older subject", "A newer subject")).toBe(false); +}); + +test("losing a name is not an arrival", () => { + expect(isNaming("Travel receipt rules", undefined)).toBe(false); +}); + +test("longer names type for longer, at the same speed", () => { + // Constant motion: twice the characters, twice the time, until the ceiling. + expect(typingSeconds(20)).toBeCloseTo(typingSeconds(10) * 2, 5); +}); + +test("a very long name is capped rather than outstaying the moment", () => { + expect(typingSeconds(500)).toBe(TYPING_MAX_SECONDS); +}); From a820e27d08af94c18f0f77d9db11aceb2c3841df Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:31:24 +0530 Subject: [PATCH 7/8] Draw the name where the last message was --- .../components/app-sidebar/app-sidebar.tsx | 14 +++-- app/src/components/app-sidebar/channel.tsx | 14 ++++- app/tests/channel-menu-mutations.test.ts | 2 + app/tests/channel-order.test.ts | 26 ++++++++- app/tests/channel-search.test.ts | 57 +++++++++++++++++++ app/tests/channel-unread.test.ts | 25 ++++++++ 6 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 app/tests/channel-search.test.ts diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index d70023f20..845c6b720 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -89,15 +89,16 @@ const MAX_ANIMATED_ROWS = 60; /** * The roster, narrowed to what the person typed. * - * Matches the channel's name and the last thing said in it, because those are the two things the - * row actually shows — searching against something invisible returns results a person cannot - * account for. Message history beyond the last line is not here to search: it lives in the thread - * store, and reaching for it is a server endpoint rather than a filter. + * Matches the channel's name, its summary, and the last message, because those are the things the + * row can actually show — searching against something invisible returns results a person cannot + * account for. The last message is included because it is still what the second line draws until the + * conversation has been named. Message history beyond that line is not here to search: it lives in + * the thread store, and reaching for it is a server endpoint rather than a filter. * * An empty query returns the input array unchanged rather than a copy, so typing and clearing does * not hand `AnimatePresence` a new array identity and restage the whole list. */ -function matchingChannels( +export function matchingChannels( channels: ChannelSummary[] | undefined, query: string, ): ChannelSummary[] { @@ -109,7 +110,7 @@ function matchingChannels( return channels; } return channels.filter((channel) => - [channel.name, channel.lastMessage].some((field) => + [channel.name, channel.summary, channel.lastMessage].some((field) => field?.toLowerCase().includes(needle), ), ); @@ -190,6 +191,7 @@ function ChannelRow({ channelId={channel.id} participantIds={channel.agentIds} name={channel.name} + summary={channel.summary ?? undefined} lastMessage={channel.lastMessage ?? undefined} lastMessageAt={ channel.lastMessageAt diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx index 194962d37..4c90414ac 100644 --- a/app/src/components/app-sidebar/channel.tsx +++ b/app/src/components/app-sidebar/channel.tsx @@ -26,6 +26,7 @@ import { deleteChannelMutationOptions, setChannelPinnedMutationOptions, } from "@/lib/channels/mutations"; +import { useTypedReveal } from "@/lib/typed-reveal"; import { ChannelAvatar } from "../channels/avatar"; /** @@ -39,6 +40,7 @@ export const Channel = memo(function Channel({ channelId, participantIds, name, + summary, lastMessage, lastMessageAt, pinned, @@ -48,6 +50,9 @@ export const Channel = memo(function Channel({ channelId: string; participantIds: string[]; name: string; + /** What the conversation is about, once named. The channel name only says which Bot it is. */ + summary?: string; + /** Shown on that same line until the conversation has been named. */ lastMessage?: string; lastMessageAt?: string; pinned: boolean; @@ -63,6 +68,8 @@ export const Channel = memo(function Channel({ select: (params) => (params as { channelId?: string }).channelId === channelId, }); + /* Types in only on arrival; an already-named row draws it outright. */ + const revealed = useTypedReveal(summary); const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient)); const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient)); const [confirming, setConfirming] = useState(false); @@ -133,7 +140,12 @@ export const Channel = memo(function Channel({
- {lastMessage} + {/* Falls back to the last message, so the line never blanks while naming runs. */} + {revealed.text ?? lastMessage} + {revealed.typing ? ( + /* Solid, not blinking: this is over in under half a second. */ + + ) : null} {unread ? ( /* State about the message beats state about the row, so it sits first. */ diff --git a/app/tests/channel-menu-mutations.test.ts b/app/tests/channel-menu-mutations.test.ts index a3404f938..ddc191023 100644 --- a/app/tests/channel-menu-mutations.test.ts +++ b/app/tests/channel-menu-mutations.test.ts @@ -102,6 +102,7 @@ test("marking read PUTs the read route and patches lastReadAt in place", async ( agentIds: ["agent-1"], threadId: "thread-1", active: true, + summary: null, lastMessage: "hello", lastMessageAt: "2026-08-25T12:00:00.000Z", lastMessageAgentId: "agent-1", @@ -146,6 +147,7 @@ test("a message stamped by a clock ahead of ours still reads as seen after marki agentIds: ["agent-1"], threadId: "thread-1", active: true, + summary: null, lastMessage: "hello", lastMessageAt: futureLastMessageAt, lastMessageAgentId: "agent-1", diff --git a/app/tests/channel-order.test.ts b/app/tests/channel-order.test.ts index ad2836965..25e3c3bd4 100644 --- a/app/tests/channel-order.test.ts +++ b/app/tests/channel-order.test.ts @@ -3,13 +3,18 @@ import { pinnedFirst } from "../src/components/app-sidebar/app-sidebar"; import type { ChannelSummary } from "../src/lib/channels/queries"; /** A minimal but fully-typed channel summary, so tests build real objects rather than casts. */ -function channel(id: string, pinned: boolean): ChannelSummary { +function channel( + id: string, + pinned: boolean, + summary: string | null = null, +): ChannelSummary { return { id, name: id, agentIds: [], threadId: `thread-${id}`, active: true, + summary, lastMessage: null, lastMessageAt: null, lastMessageAgentId: null, @@ -51,3 +56,22 @@ test("leaves an all-unpinned roster in its original order", () => { expect(pinnedFirst(channels).map((c) => c.id)).toEqual(["a", "b", "c"]); }); + +test("a title changes nothing about where a row sits", () => { + // Naming a conversation is not activity in it. Whatever the roster order was, it is the same + // order once titles arrive, or rows would appear to jump for no reason anybody could see. + const untitled = [ + channel("a", false), + channel("b", true), + channel("c", false), + ]; + const titled = [ + channel("a", false, "Expense categories"), + channel("b", true, "Quarterly revenue"), + channel("c", false, "Vendor onboarding"), + ]; + + expect(pinnedFirst(titled).map((c) => c.id)).toEqual( + pinnedFirst(untitled).map((c) => c.id), + ); +}); diff --git a/app/tests/channel-search.test.ts b/app/tests/channel-search.test.ts new file mode 100644 index 000000000..c01f60c85 --- /dev/null +++ b/app/tests/channel-search.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { matchingChannels } from "../src/components/app-sidebar/app-sidebar"; +import type { ChannelSummary } from "../src/lib/channels/queries"; + +/** A roster row as the search box sees it. */ +function channel(overrides: Partial): ChannelSummary { + return { + id: "channel-1", + name: "Knowledge", + agentIds: ["agent-1"], + threadId: "thread-1", + active: true, + summary: null, + lastMessage: "The three flights and the hotel do.", + lastMessageAt: "2026-08-25T12:00:00.000Z", + lastMessageAgentId: "agent-1", + createdAt: "2026-08-25T11:00:00.000Z", + pinned: false, + lastReadAt: null, + ...overrides, + }; +} + +test("an untitled channel is still found by its Bot's name", () => { + // The fallback the row draws is the name, so the name has to stay searchable or a conversation + // that has not been named yet becomes unreachable from the search box. + const rows = [channel({ id: "untitled" })]; + + expect(matchingChannels(rows, "knowl").map((row) => row.id)).toEqual([ + "untitled", + ]); +}); + +test("a titled channel is found by a word in its title", () => { + const rows = [channel({ id: "titled", summary: "Travel receipt rules" })]; + + expect(matchingChannels(rows, "receipt").map((row) => row.id)).toEqual([ + "titled", + ]); +}); + +test("a word from the last message still matches", () => { + // The second line falls back to the last message until a conversation is named, so that text is + // still something the roster can show and therefore still something search must find. + const rows = [channel({ id: "titled", summary: "Travel receipt rules" })]; + + expect(matchingChannels(rows, "hotel").map((row) => row.id)).toEqual([ + "titled", + ]); +}); + +test("an empty query returns the very same array, not a copy", () => { + // Identity matters: a new array on every keystroke restages the whole animated list. + const rows = [channel({})]; + + expect(matchingChannels(rows, " ")).toBe(rows); +}); diff --git a/app/tests/channel-unread.test.ts b/app/tests/channel-unread.test.ts index 40192e3e8..a4f9e3cd5 100644 --- a/app/tests/channel-unread.test.ts +++ b/app/tests/channel-unread.test.ts @@ -13,6 +13,7 @@ function channel(overrides: Partial): ChannelSummary { agentIds: ["agent-1"], threadId: "thread-1", active: true, + summary: null, lastMessage: "hello", lastMessageAt: "2026-08-25T12:00:00.000Z", lastMessageAgentId: "agent-1", @@ -60,3 +61,27 @@ test("the open channel is never unread, however unseen its activity", () => { expect(isUnread(channel({}), "channel-2")).toBe(true); expect(isUnread(channel({}), undefined)).toBe(true); }); + +/* + * The roster row draws the summary and no longer draws the last message, so the fields the unread + * rule reads are now invisible on screen. That makes them easy for a later change to believe unused. + * These two pin the dependency: the dot is a fact about the message, never about the title. + */ +test("a titled channel is still unseen on its Bot's message", () => { + expect( + hasUnseenActivity( + channel({ summary: "Expense categories", lastMessage: null }), + ), + ).toBe(true); +}); + +test("a title does not make a read channel unseen again", () => { + expect( + hasUnseenActivity( + channel({ + summary: "Expense categories", + lastReadAt: "2026-08-25T12:30:00.000Z", + }), + ), + ).toBe(false); +}); From 089366c38fc73da26281046946db676b7076afa3 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Tue, 1 Sep 2026 22:51:12 +0530 Subject: [PATCH 8/8] Say on the changelog that conversations get names --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e0438..e4eb2aaba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,15 @@ to authenticate. In the same spirit, the handoff panel explains once when a cowo work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the server can only refuse; its existing grants stay visible so they can still be revoked. +### A conversation is given a name of its own + +A channel was labelled with the names of the Bots in it, so every conversation with the same +coworker read the same on the roster, and the only thing telling two of them apart was a preview of +whatever was said last. That preview is usually the tail of an answer, which says nothing about the +question that prompted it. Once a conversation has an opening exchange, the deployment's own model +is asked for a few words naming what it is about, and the roster draws those words in place of the +preview. A deployment with no model key configured names nothing and looks exactly as it did before. + ### A channel stops showing a working indicator once its turn has ended Sending a message and then opening another channel before the reply arrived left the first channel