diff --git a/packages/junior/migrations/0004_big_malice.sql b/packages/junior/migrations/0004_big_malice.sql new file mode 100644 index 000000000..c8222f125 --- /dev/null +++ b/packages/junior/migrations/0004_big_malice.sql @@ -0,0 +1,4 @@ +ALTER TABLE "junior_conversations" ADD COLUMN "execution_model_profile" text;--> statement-breakpoint +ALTER TABLE "junior_conversations" ADD COLUMN "execution_reasoning_level" text;--> statement-breakpoint +ALTER TABLE "junior_conversations" ADD COLUMN "execution_instructions" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint +ALTER TABLE "junior_conversations" ADD COLUMN "execution_allowed_tool_names" text[]; \ No newline at end of file diff --git a/packages/junior/migrations/0005_solid_franklin_storm.sql b/packages/junior/migrations/0005_solid_franklin_storm.sql new file mode 100644 index 000000000..35ee6002e --- /dev/null +++ b/packages/junior/migrations/0005_solid_franklin_storm.sql @@ -0,0 +1,9 @@ +CREATE TABLE "junior_conversation_turns" ( + "conversation_id" text NOT NULL, + "turn_id" text NOT NULL, + "starting_seq" integer NOT NULL, + CONSTRAINT "junior_conversation_turns_conversation_id_turn_id_pk" PRIMARY KEY("conversation_id","turn_id") +); +--> statement-breakpoint +ALTER TABLE "junior_conversation_turns" ADD CONSTRAINT "junior_conversation_turns_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_conversation_turns" ADD CONSTRAINT "junior_conversation_turns_starting_step_fk" FOREIGN KEY ("conversation_id","starting_seq") REFERENCES "public"."junior_agent_steps"("conversation_id","seq") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0004_snapshot.json b/packages/junior/migrations/meta/0004_snapshot.json new file mode 100644 index 000000000..82cd4ce52 --- /dev/null +++ b/packages/junior/migrations/meta/0004_snapshot.json @@ -0,0 +1,1018 @@ +{ + "id": "8af4c564-90d1-4e92-aa71-e7f110d28c3d", + "prevId": "3668b705-7991-4e7e-8fb2-6e4ef8147aac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "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 + } + }, + "indexes": { + "junior_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "tableTo": "junior_identities", + "columnsFrom": ["author_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_model_profile": { + "name": "execution_model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_reasoning_level": { + "name": "execution_reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_instructions": { + "name": "execution_instructions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "execution_allowed_tool_names": { + "name": "execution_allowed_tool_names", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0005_snapshot.json b/packages/junior/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..07b9ef5d6 --- /dev/null +++ b/packages/junior/migrations/meta/0005_snapshot.json @@ -0,0 +1,1073 @@ +{ + "id": "06d3da23-7f0e-4956-a7d4-8cebc5c1eda4", + "prevId": "8af4c564-90d1-4e92-aa71-e7f110d28c3d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "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 + } + }, + "indexes": { + "junior_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "tableTo": "junior_identities", + "columnsFrom": ["author_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_turns": { + "name": "junior_conversation_turns", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starting_seq": { + "name": "starting_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_turns_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_turns_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_turns", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_turns_starting_step_fk": { + "name": "junior_conversation_turns_starting_step_fk", + "tableFrom": "junior_conversation_turns", + "tableTo": "junior_agent_steps", + "columnsFrom": ["conversation_id", "starting_seq"], + "columnsTo": ["conversation_id", "seq"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_turns_conversation_id_turn_id_pk": { + "name": "junior_conversation_turns_conversation_id_turn_id_pk", + "columns": ["conversation_id", "turn_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_model_profile": { + "name": "execution_model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_reasoning_level": { + "name": "execution_reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_instructions": { + "name": "execution_instructions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "execution_allowed_tool_names": { + "name": "execution_allowed_tool_names", + "type": "text[]", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 1da614f71..5c7e493f4 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1783915376595, "tag": "0003_peaceful_scalphunter", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783976272513, + "tag": "0004_big_malice", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783985660251, + "tag": "0005_solid_franklin_storm", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/junior/src/api/conversations/detail-projection.ts b/packages/junior/src/api/conversations/detail-projection.ts index 427c36931..a7676f72f 100644 --- a/packages/junior/src/api/conversations/detail-projection.ts +++ b/packages/junior/src/api/conversations/detail-projection.ts @@ -175,7 +175,10 @@ function historyContent(args: { }); } - if (marker?.entry.reason === "rollback") { + if ( + marker?.entry.reason === "rollback" || + marker?.entry.reason === "model_change" + ) { messages.push( ...projected.slice(matchingPrefix(previousProjection, projected)), ); diff --git a/packages/junior/src/api/conversations/detail.query.ts b/packages/junior/src/api/conversations/detail.query.ts index cb2957d9b..5bce4fb0b 100644 --- a/packages/junior/src/api/conversations/detail.query.ts +++ b/packages/junior/src/api/conversations/detail.query.ts @@ -1,19 +1,28 @@ import { logException } from "@/chat/logging"; import { + getAgentTurnSessionRecord, listBoundedAgentTurnSessionSummariesForConversation, type AgentTurnSessionSummary, } from "@/chat/state/turn-session"; import { buildConversationDetail } from "./detail-projection"; import { readConversationRecordFromSql } from "./list.query"; import type { ConversationDetailReport } from "./schema"; +import { getConversationTurnStore } from "@/chat/db"; -async function readLatestRun( +async function readReportedRun( conversationId: string, ): Promise { try { - return ( - await listBoundedAgentTurnSessionSummariesForConversation(conversationId) - ).find((summary) => summary.modelId || summary.reasoningLevel); + const summaries = + await listBoundedAgentTurnSessionSummariesForConversation(conversationId); + const summary = summaries[0]; + if (!summary) { + return undefined; + } + if (summary?.modelId || summary?.reasoningLevel) { + return summary; + } + return await getAgentTurnSessionRecord(conversationId, summary.sessionId); } catch (error) { logException(error, "conversation_execution_settings_read_failed", { conversationId, @@ -22,6 +31,20 @@ async function readLatestRun( } } +async function readLatestTurnModelId( + conversationId: string, + turnId: string | undefined, +): Promise { + if (!turnId) { + return undefined; + } + const turn = await getConversationTurnStore().get(conversationId, turnId); + if (!turn) { + return undefined; + } + return turn.startingModelId; +} + /** Read one SQL conversation with its latest operational run settings. */ export async function readConversationDetailFromSql( conversationId: string, @@ -34,11 +57,18 @@ export async function readConversationDetailFromSql( ...record, usage: record.usage ?? undefined, }), - readLatestRun(conversationId), + readReportedRun(conversationId), ]); + const modelId = await readLatestTurnModelId( + conversationId, + latestRun?.sessionId ?? record.conversation.execution.runId, + ); + // TODO(v0.102.0): Remove the session-model fallback after every retained + // conversation turn was created after junior_conversation_turns shipped. + const reportedModelId = modelId ?? latestRun?.modelId; return { ...report, - ...(latestRun?.modelId ? { modelId: latestRun.modelId } : {}), + ...(reportedModelId ? { modelId: reportedModelId } : {}), ...(latestRun?.reasoningLevel ? { reasoningLevel: latestRun.reasoningLevel } : {}), diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 7cee99c19..e2e798e3c 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -8,10 +8,16 @@ import { getConfigDefaults, setConfigDefaults, } from "@/chat/configuration/defaults"; -import { getSlackReactionConfig, setSlackReactionConfig } from "@/chat/config"; -import { getDb } from "@/chat/db"; +import { + botConfig, + getSlackReactionConfig, + setSlackReactionConfig, +} from "@/chat/config"; +import { getConversationExecutionProfileStore, getDb } from "@/chat/db"; import { logException } from "@/chat/logging"; import { executeAgentRun } from "@/chat/agent"; +import { createConversationRuntime } from "@/chat/runtime/conversation-runtime"; +import { defaultConversationExecutionProfile } from "@/chat/conversations/execution-profile"; import { normalizeSandboxEgressTracePropagationDomains } from "@/chat/sandbox/egress/tracing"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import { @@ -587,8 +593,12 @@ export async function createApp(options?: JuniorAppOptions): Promise { const waitUntil = options?.waitUntil ?? (await defaultWaitUntil()); const tracePropagation = { domains: sandboxEgressTracePropagationDomains }; - const agentRunner = createAgentRunner(executeAgentRun, { - tracePropagation, + const agentRunner = createConversationRuntime({ + agentRunner: createAgentRunner(executeAgentRun, { + tracePropagation, + }), + defaultProfile: defaultConversationExecutionProfile(botConfig), + profileStore: getConversationExecutionProfileStore(), }); const runtimeServiceOverrides = { replyExecutor: { agentRunner }, diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index ae8b80de9..15adbeb0c 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -39,6 +39,7 @@ import { recordToolExecutionStarted, recordMcpProviderConnected, } from "@/chat/conversations/projection"; +import { getConversationTurnStore } from "@/chat/db"; import { instructionActors, instructionProvenanceFor, @@ -72,6 +73,7 @@ import { configuredTurnReasoningLevel, selectTurnReasoningLevel, toPiReasoningLevel, + type TurnReasoningSelection, } from "@/chat/services/turn-reasoning-level"; import { addAgentTurnUsage, @@ -114,6 +116,7 @@ import { } from "@/chat/model-profile"; import { compactContextForHandoff } from "@/chat/services/context-compaction"; import { HANDOFF_TOOL_NAME } from "@/chat/tools/handoff/tool"; +import { parseTurnReasoningLevel } from "@/chat/reasoning-level"; const AGENT_ABORT_SETTLE_GRACE_MS = 5_000; @@ -211,16 +214,26 @@ async function executeAgentRunInPrivacyContext( let canRecordMcpProviders = false; let turnUsage: AgentTurnUsage | undefined; let handoffPhaseUsage: AgentTurnUsage | undefined; - const configuredReasoningLevel = - policy.reasoningLevel ?? botConfig.reasoningLevel; - let reasoningSelection = configuredReasoningLevel - ? configuredTurnReasoningLevel( - configuredReasoningLevel, - policy.reasoningLevel ? "agent_config" : "default", - ) - : undefined; - let activeModelProfile: ModelProfile = STANDARD_MODEL_PROFILE; + let configuredReasoningLevel = botConfig.reasoningLevel; + let reasoningSource: "agent_config" | "default" = "default"; + if (policy.reasoningPolicy) { + configuredReasoningLevel = undefined; + if (policy.reasoningPolicy.mode === "fixed") { + configuredReasoningLevel = policy.reasoningPolicy.level; + reasoningSource = "agent_config"; + } + } + let reasoningSelection: TurnReasoningSelection | undefined; + if (configuredReasoningLevel) { + reasoningSelection = configuredTurnReasoningLevel( + configuredReasoningLevel, + reasoningSource, + ); + } + let activeModelProfile: ModelProfile = + policy.modelProfile ?? STANDARD_MODEL_PROFILE; let activeModelId = modelIdForProfile(botConfig, activeModelProfile); + let startingModelId = activeModelId; const actor = actorFromRouting(routing); const surface = surfaceFromRouting(routing); const runSource = routing.source; @@ -273,13 +286,52 @@ async function executeAgentRunInPrivacyContext( }); try { + const { + sessionRecordState, + resumedFromSessionRecord, + currentSliceId, + existingSessionRecord, + } = await restoreSessionRecord(routing); + const durableTurnId = sessionId ?? routing.correlation?.runId; + if (sessionConversationId && !durableTurnId) { + throw new TypeError("Durable agent run requires a turn or run id"); + } + let startingSeq: number | undefined; if (sessionConversationId) { const projection = await openConversationProjection({ conversationId: sessionConversationId, - modelId: activeModelId, + initialModelProfile: activeModelProfile, + ...(resumedFromSessionRecord && existingSessionRecord?.modelId + ? { pinnedModelId: existingSessionRecord.modelId } + : {}), + refreshModelBinding: !resumedFromSessionRecord, + resolveModelId: (profile) => modelIdForProfile(botConfig, profile), }); activeModelProfile = projection.modelProfile; - activeModelId = modelIdForProfile(botConfig, activeModelProfile); + if (!projection.modelId) { + throw new Error("Conversation projection has no executable model"); + } + activeModelId = projection.modelId; + startingSeq = projection.startingSeq; + } + startingModelId = activeModelId; + if (sessionConversationId && durableTurnId && startingSeq !== undefined) { + if (resumedFromSessionRecord) { + const turn = await getConversationTurnStore().get( + sessionConversationId, + durableTurnId, + ); + if (turn) { + startingModelId = turn.startingModelId; + } + } else { + const turn = await getConversationTurnStore().recordStart({ + conversationId: sessionConversationId, + turnId: durableTurnId, + startingSeq, + }); + startingModelId = turn.startingModelId; + } } const shouldTrace = shouldEmitDevAgentTrace(); const spanContext: LogContext = { @@ -329,12 +381,15 @@ async function executeAgentRunInPrivacyContext( const skillSandbox = new SkillSandbox(availableSkills, activeSkills); // ── Turn session record ────────────────────────────────────────── - const { - sessionRecordState, - resumedFromSessionRecord, - currentSliceId, - existingSessionRecord, - } = await restoreSessionRecord(routing); + const fixedReasoningConfigured = + policy.reasoningPolicy?.mode === "fixed" || + (!policy.reasoningPolicy && Boolean(botConfig.reasoningLevel)); + if (existingSessionRecord?.reasoningLevel && !fixedReasoningConfigured) { + reasoningSelection = configuredTurnReasoningLevel( + parseTurnReasoningLevel(existingSessionRecord.reasoningLevel), + "resume", + ); + } // Mirror the committed provenance prefix the turn session record owns. A // fresh run may already include batched parked input committed before the // agent starts, then adds the current actor's turn-start instruction. @@ -624,6 +679,7 @@ async function executeAgentRunInPrivacyContext( existingTurnStartMessageIndex: existingSessionRecord?.turnStartMessageIndex, invocation: skillInvocation, + instructions: policy.instructions, priorPiMessages, resumedFromSessionRecord, routing, @@ -1108,6 +1164,7 @@ async function executeAgentRunInPrivacyContext( reasoningSelection, correlation: routing.correlation, assistantUserName: botConfig.userName, + startingModelId, modelId: activeModelId, }), }; @@ -1164,6 +1221,7 @@ async function executeAgentRunInPrivacyContext( ...getSandboxMetadata(), diagnostics: { outcome: "provider_error", + startingModelId, modelId: activeModelId, assistantMessageCount: 0, ...(reasoningSelection diff --git a/packages/junior/src/chat/agent/prompt.ts b/packages/junior/src/chat/agent/prompt.ts index 4c01d1851..d2682bb88 100644 --- a/packages/junior/src/chat/agent/prompt.ts +++ b/packages/junior/src/chat/agent/prompt.ts @@ -352,6 +352,7 @@ export async function assemblePrompt(args: { existingSessionPiMessages?: PiMessage[]; existingTurnStartMessageIndex?: number; invocation: SkillInvocation | null; + instructions?: string[]; priorPiMessages?: PiMessage[]; resumedFromSessionRecord: boolean; routing: AgentRunRouting; @@ -387,7 +388,11 @@ export async function assemblePrompt(args: { const pluginSystemPrompt = buildPluginSystemPromptContributions( systemPromptContributions, ); - const baseInstructions = [buildSystemPrompt({ source }), pluginSystemPrompt] + const baseInstructions = [ + buildSystemPrompt({ source }), + pluginSystemPrompt, + ...(args.instructions ?? []), + ] .filter((section): section is string => Boolean(section)) .join("\n\n"); const pluginUserPromptContributions = !shouldPromptAgent diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index e09e26dbf..6131ea793 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -27,7 +27,11 @@ import type { ConversationPendingAuthState } from "@/chat/state/conversation"; import type { PiMessageProvenance } from "@/chat/state/session-log"; import type { AgentTurnSurface } from "@/chat/state/turn-session"; import type { ToolExecutionReport } from "@/chat/tool-support/tool-execution-report"; -import type { TurnReasoningLevel } from "@/chat/reasoning-level"; +import type { ModelProfile } from "@/chat/model-profile"; +import type { + ConversationReasoningPolicy, + ConversationToolPolicy, +} from "@/chat/conversations/execution-profile"; import type { ImageGenerateToolDeps, WebFetchToolDeps, @@ -105,8 +109,14 @@ export interface AgentRunPolicy { /** Cancels provider work when the owning host request is abandoned. */ signal?: AbortSignal; authorizationFlowMode?: AuthorizationFlowMode; - /** Explicit per-agent reasoning level. When set, adaptive routing is disabled. */ - reasoningLevel?: TurnReasoningLevel; + /** Explicit reasoning behavior for this agent run. */ + reasoningPolicy?: ConversationReasoningPolicy; + /** Baseline model role for a conversation before any durable handoff. */ + modelProfile?: ModelProfile; + /** Conversation-owned additions to the system instructions. */ + instructions?: string[]; + /** Conversation-owned restriction over the host-provided tool set. */ + toolPolicy?: ConversationToolPolicy; configuration?: Record; channelConfiguration?: ChannelConfigurationService; skillDirs?: string[]; diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 74deff0d2..bf3f6c5a6 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -58,6 +58,9 @@ import { createLazySandboxWorkspace } from "@/chat/agent/sandbox"; import { upsertActiveSkill } from "@/chat/agent/skills"; import type { ResumeState } from "@/chat/agent/resume"; import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts"; +import type { ConversationToolPolicy } from "@/chat/conversations/execution-profile"; +import { EXECUTE_TOOL_NAME } from "@/chat/tools/execute-tool"; +import { SEARCH_TOOLS_NAME } from "@/chat/tools/search-tools"; interface ToolWiringArgs { abortAgent: () => void; @@ -111,6 +114,13 @@ export interface ToolWiring { toolRuntimeContext: ToolRuntimeContext; } +function toolAllowed( + policy: ConversationToolPolicy | undefined, + name: string, +): boolean { + return policy?.mode !== "allowlist" || policy.toolNames.includes(name); +} + /** Wire sandbox, auth orchestration, MCP restoration, and Pi tool surfaces for one slice. */ export async function wireAgentTools( args: ToolWiringArgs, @@ -340,8 +350,14 @@ export async function wireAgentTools( toolRuntimeContext, ); - const plannedToolExposure = planToolExposure( - tools as Record, + const allowedTools = Object.fromEntries( + Object.entries(tools).filter(([name]) => + toolAllowed(args.policy.toolPolicy, name), + ), + ) as Record; + const plannedToolExposure = planToolExposure(allowedTools); + const hasDeferredTools = Object.keys(plannedToolExposure.catalogTools).some( + (name) => !plannedToolExposure.directTools[name], ); const toolGuidance = Object.entries(plannedToolExposure.directTools).map( ([name, definition]) => ({ @@ -398,9 +414,12 @@ export async function wireAgentTools( } } - const activeMcpCatalogs = toActiveMcpCatalogSummaries( - mcpToolManager.getActiveToolCatalog(), - ); + let activeMcpCatalogs: ReturnType = []; + if (toolAllowed(args.policy.toolPolicy, "searchMcpTools")) { + activeMcpCatalogs = toActiveMcpCatalogSummaries( + mcpToolManager.getActiveToolCatalog(), + ); + } const onToolCall = async ( toolName: string, params: Record, @@ -422,7 +441,7 @@ export async function wireAgentTools( } }; const agentTools = createPiAgentTools( - tools, + allowedTools, args.skillSandbox, args.spanContext, args.observers.onStatus, @@ -432,7 +451,14 @@ export async function wireAgentTools( pluginHooks, args.conversationPrivacy, args.observers.onToolResult, - ); + ).filter((tool) => { + const isCatalogDispatcher = + tool.name === SEARCH_TOOLS_NAME || tool.name === EXECUTE_TOOL_NAME; + if (isCatalogDispatcher && hasDeferredTools) { + return true; + } + return toolAllowed(args.policy.toolPolicy, tool.name); + }); // Keep Pi's native tool schema static for the whole turn. Ideally this // would use provider-native tool loading/search APIs, but Pi's generic // AgentTool surface cannot yet express OpenAI/Anthropic deferred MCP tools. @@ -447,7 +473,9 @@ export async function wireAgentTools( mcpToolManager, pluginHooks, sandboxExecutor, - toolGuidance, + toolGuidance: toolGuidance.filter((tool) => + toolAllowed(args.policy.toolPolicy, tool.name), + ), toolRuntimeContext, }; } diff --git a/packages/junior/src/chat/app/services.ts b/packages/junior/src/chat/app/services.ts index eb3164a75..dd8d130ef 100644 --- a/packages/junior/src/chat/app/services.ts +++ b/packages/junior/src/chat/app/services.ts @@ -31,6 +31,10 @@ import { type VisionContextService, } from "@/chat/slack/vision-context"; import { createAgentRunner } from "@/chat/runtime/agent-runner"; +import { createConversationRuntime } from "@/chat/runtime/conversation-runtime"; +import { defaultConversationExecutionProfile } from "@/chat/conversations/execution-profile"; +import { getConversationExecutionProfileStore } from "@/chat/db"; +import { botConfig } from "@/chat/config"; export interface JuniorRuntimeServices { conversationMemory: ConversationMemoryService; @@ -78,8 +82,12 @@ export function createJuniorRuntimeServices( overrides.replyExecutor?.contextCompactor ?? contextCompactor, agentRunner: overrides.replyExecutor?.agentRunner ?? - createAgentRunner(executeAgentRunImpl, { - tracePropagation: overrides.sandbox?.tracePropagation, + createConversationRuntime({ + agentRunner: createAgentRunner(executeAgentRunImpl, { + tracePropagation: overrides.sandbox?.tracePropagation, + }), + defaultProfile: defaultConversationExecutionProfile(botConfig), + profileStore: getConversationExecutionProfileStore(), }), getAwaitingAgentContinueRequest: overrides.replyExecutor?.getAwaitingAgentContinueRequest ?? diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index 65694bf78..35090a3fe 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -7,15 +7,35 @@ agent steps, compaction boundaries, search, retention, and legacy import. - Conversation rows identify the source, destination, participants, visibility, and lifecycle metadata. +- Conversation rows carry typed execution columns for the provider-neutral + behavior shared by every execution slice. - Visible messages are the destination-facing user and assistant history. +- Conversation turns identify each user request and anchor it to the starting + agent-step boundary. The containing context epoch owns the exact model. - Agent steps are append-only execution history used to restore Pi state. -- Context epochs identify replacement boundaries created by compaction or model - handoff. +- Context epochs identify replacement boundaries created by compaction, model + change, handoff, or rollback. - Provider payloads and old state-store mirrors are migration inputs, not canonical product records. The schemas and migrations under `sql/` are authoritative. +## Execution Profiles + +- `ConversationExecutionProfileStore` atomically materializes or loads one + immutable profile for a conversation. +- The first call persists current host defaults when the profile is absent; + later calls return the stored value rather than replacing it. +- `execution_model_profile` marks a materialized profile. + `execution_reasoning_level` is null for adaptive reasoning, + `execution_allowed_tool_names` is null for the host tool set, and + `execution_instructions` stores the durable instruction list. +- Model profile roles remain stable durable values. A fresh turn resolves the + active profile through the current host catalog; if the exact model changed, + it opens a `model_change` epoch before execution. +- Every context epoch binds one exact model. Resumed slices, compaction, and + rollback preserve that binding; handoff explicitly opens a different one. + ## Write Rules - Persist user input before agent execution. diff --git a/packages/junior/src/chat/conversations/execution-profile.ts b/packages/junior/src/chat/conversations/execution-profile.ts new file mode 100644 index 000000000..4a2ab9716 --- /dev/null +++ b/packages/junior/src/chat/conversations/execution-profile.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import type { BotConfig } from "@/chat/config"; +import { + modelProfileSchema, + STANDARD_MODEL_PROFILE, +} from "@/chat/model-profile"; +import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; + +export const conversationToolPolicySchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("host") }).strict(), + z + .object({ + mode: z.literal("allowlist"), + toolNames: z.array(z.string().min(1)), + }) + .strict(), +]); + +export const conversationReasoningPolicySchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("adaptive") }).strict(), + z + .object({ + mode: z.literal("fixed"), + level: z.enum(TURN_REASONING_LEVELS), + }) + .strict(), +]); + +/** Immutable conversation configuration used for each execution slice. */ +export const conversationExecutionProfileSchema = z + .object({ + modelProfile: modelProfileSchema, + reasoning: conversationReasoningPolicySchema, + instructions: z.array(z.string().trim().min(1)), + toolPolicy: conversationToolPolicySchema, + }) + .strict(); + +export type ConversationExecutionProfile = z.output< + typeof conversationExecutionProfileSchema +>; + +export type ConversationToolPolicy = z.output< + typeof conversationToolPolicySchema +>; + +export type ConversationReasoningPolicy = z.output< + typeof conversationReasoningPolicySchema +>; + +/** Persist or load the immutable execution profile for one conversation. */ +export interface ConversationExecutionProfileStore { + getOrCreateExecutionProfile(args: { + conversationId: string; + profile: ConversationExecutionProfile; + nowMs?: number; + }): Promise; +} + +/** Capture current host defaults as a stable profile for a new conversation. */ +export function defaultConversationExecutionProfile( + config: BotConfig, +): ConversationExecutionProfile { + let reasoning: ConversationReasoningPolicy = { mode: "adaptive" }; + if (config.reasoningLevel) { + reasoning = { mode: "fixed", level: config.reasoningLevel }; + } + return { + modelProfile: STANDARD_MODEL_PROFILE, + reasoning, + instructions: [], + toolPolicy: { mode: "host" }, + }; +} diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index a60f986d7..a8d44e9ce 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -2,9 +2,9 @@ * Durable agent execution history port. * * Steps are appended one row at a time under the conversation lease; context - * rebuilds (compaction, handoff, rollback) open a new context epoch instead of - * rewriting history. Each new epoch binds the model profile that owns it and - * records the exact resolved model id for audit. + * rebuilds (compaction, handoff, model change, rollback) open a new context + * epoch instead of rewriting history. Each epoch binds one model profile and + * exact model id; every execution using that epoch must use that binding. * The step envelope is strictly validated — an unknown type or malformed shape * is corrupt state and fails loudly — while `pi_message` content stays * permissive because the Pi SDK owns the message shape. @@ -34,7 +34,7 @@ const contextEpochStartedEntrySchema = z.union([ .object({ type: z.literal("context_epoch_started"), reason: z.literal("initial"), - modelProfile: z.literal("standard"), + modelProfile: modelProfileSchema, modelId: z.string().min(1), }) .strict(), @@ -46,6 +46,14 @@ const contextEpochStartedEntrySchema = z.union([ modelId: z.string().min(1), }) .strict(), + z + .object({ + type: z.literal("context_epoch_started"), + reason: z.literal("model_change"), + modelProfile: modelProfileSchema, + modelId: z.string().min(1), + }) + .strict(), z .object({ type: z.literal("context_epoch_started"), @@ -79,7 +87,7 @@ export const contextEpochStartSchema = z.discriminatedUnion("reason", [ z .object({ reason: z.literal("initial"), - modelProfile: z.literal("standard"), + modelProfile: modelProfileSchema, modelId: z.string().min(1), messages: z.array(piMessageStepSchema), }) @@ -92,6 +100,14 @@ export const contextEpochStartSchema = z.discriminatedUnion("reason", [ messages: z.array(piMessageStepSchema), }) .strict(), + z + .object({ + reason: z.literal("model_change"), + modelProfile: modelProfileSchema, + modelId: z.string().min(1), + messages: z.array(piMessageStepSchema), + }) + .strict(), z .object({ reason: z.union([z.literal("compaction"), z.literal("rollback")]), diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 2b821c9ab..5c1c2ab05 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -6,10 +6,9 @@ * The current model context is exactly the highest context epoch's `pi_message` * steps in `seq` order; host-only step types (activity, provider connection, * authorization requests, epoch markers) never reach `agent.state.messages`. - * Compaction, handoff, and rollback open a new epoch instead of rewriting - * history, so the context reducer only walks one epoch. The epoch marker binds - * the projection's model profile, which later replacements inherit, and the - * exact resolved model id used when the epoch was opened for audit. + * Compaction, handoff, model change, and rollback open a new epoch instead of + * rewriting history, so the context reducer only walks one epoch. The epoch + * marker binds the exact model used by every execution in that epoch. */ import { isDeepStrictEqual } from "node:util"; import type { PiMessage } from "@/chat/pi/messages"; @@ -47,6 +46,12 @@ export interface StepProjection extends ConversationProjection { seqs: number[]; } +/** Projection opened at the durable execution-log boundary for a turn. */ +export interface OpenConversationProjection extends ConversationProjection { + /** Latest durable step before this turn appends execution history. */ + startingSeq: number; +} + /** * Synthesize the host observation a completed authorization contributes to Pi. * Mirrors the legacy session-log projection so a resumed run still learns the @@ -232,37 +237,96 @@ export async function loadConversationProjection( return { messages, provenance, modelProfile, modelId }; } -/** Open a standard initial epoch before a conversation's first model request. */ +/** Open the configured initial epoch before a conversation's first model request. */ export async function openConversationProjection( - args: ScopedConversation & { modelId: string }, -): Promise { + args: ScopedConversation & { + initialModelProfile: ModelProfile; + pinnedModelId?: string; + refreshModelBinding: boolean; + resolveModelId: (profile: ModelProfile) => string; + }, +): Promise { await importLegacyIfNeeded(args); const stepStore = getAgentStepStore(); const steps = await stepStore.loadCurrentEpoch(args.conversationId); const projection = projectSteps(steps); - if ( - steps.some( - (step) => - step.entry.type === "context_epoch_started" || - step.entry.type === "pi_message", - ) - ) { - return projection; + const hasEpochMarker = steps.some( + (step) => step.entry.type === "context_epoch_started", + ); + if (!hasEpochMarker && projection.messages.length > 0) { + const modelId = + args.pinnedModelId ?? args.resolveModelId(args.initialModelProfile); + await stepStore.startEpoch(args.conversationId, { + reason: "model_change", + modelProfile: args.initialModelProfile, + modelId, + messages: projection.messages.map((message, index) => ({ + message, + createdAtMs: messageTimestamp(message), + provenance: projection.provenance[index]!, + })), + }); + const bound = await stepStore.loadCurrentEpoch(args.conversationId); + const startingSeq = bound.at(-1)?.seq; + if (startingSeq === undefined) { + throw new Error("Conversation legacy context binding was not persisted"); + } + return { ...projectSteps(bound), startingSeq }; + } + if (hasEpochMarker) { + let nextModelId: string | undefined; + if (!projection.modelId) { + nextModelId = args.pinnedModelId; + if (!nextModelId && args.refreshModelBinding) { + nextModelId = args.resolveModelId(projection.modelProfile); + } + if (!nextModelId) { + throw new Error("Conversation context epoch has no model binding"); + } + } else if (args.refreshModelBinding) { + const resolvedModelId = args.resolveModelId(projection.modelProfile); + if (resolvedModelId !== projection.modelId) { + nextModelId = resolvedModelId; + } + } + if (nextModelId) { + await stepStore.startEpoch(args.conversationId, { + reason: "model_change", + modelProfile: projection.modelProfile, + modelId: nextModelId, + messages: projection.messages.map((message, index) => ({ + message, + createdAtMs: messageTimestamp(message), + provenance: projection.provenance[index]!, + })), + }); + const changed = await stepStore.loadCurrentEpoch(args.conversationId); + const startingSeq = changed.at(-1)?.seq; + if (startingSeq === undefined) { + throw new Error("Conversation model change was not persisted"); + } + return { ...projectSteps(changed), startingSeq }; + } + const startingSeq = steps.at(-1)?.seq; + if (startingSeq === undefined) { + throw new Error("Conversation projection has no durable start boundary"); + } + return { ...projection, startingSeq }; } // Host facts may predate the first model request. Keep them in epoch 0 and // make that formerly implicit epoch explicit before model execution. await stepStore.startEpoch(args.conversationId, { reason: "initial", - modelProfile: "standard", - modelId: args.modelId, + modelProfile: args.initialModelProfile, + modelId: args.resolveModelId(args.initialModelProfile), messages: [], }); - return { - messages: projection.messages, - provenance: projection.provenance, - modelProfile: "standard", - modelId: args.modelId, - }; + const opened = await stepStore.loadCurrentEpoch(args.conversationId); + const startingSeq = opened.at(-1)?.seq; + if (startingSeq === undefined) { + throw new Error("Conversation projection start was not persisted"); + } + return { ...projectSteps(opened), startingSeq }; } /** @@ -322,12 +386,12 @@ function messageTimestamp(message: PiMessage): number { * provider-retry trim regenerated trailing assistant output). Returns the * resolved provenance, the per-message step seqs, and the `seq` boundary that * reproduces exactly the committed messages. A first commit atomically opens - * the standard initial epoch; the run boundary opens it earlier when a model + * the baseline initial epoch; the run boundary opens it earlier when a model * may act before any session checkpoint, such as recordless handoff. */ export async function commitMessages(args: { conversationId: string; - /** Exact model selected for this write; persisted for epoch audit only. */ + /** Exact model selected for this write; must match the current epoch. */ modelId: string; messages: PiMessage[]; /** Explicit per-message provenance aligned one-to-one with `messages`. */ @@ -344,6 +408,9 @@ export async function commitMessages(args: { const stepStore = getAgentStepStore(); const currentSteps = await stepStore.loadCurrentEpoch(args.conversationId); const existing = projectSteps(currentSteps); + const hasEpochMarker = currentSteps.some( + (step) => step.entry.type === "context_epoch_started", + ); const matchingPrefix = countMatchingPrefix(existing.messages, args.messages); const nextProvenance = resolveCommitProvenance({ existing, @@ -357,7 +424,7 @@ export async function commitMessages(args: { ? { newMessageProvenance: args.newMessageProvenance } : {}), }); - if (currentSteps.length === 0) { + if (!hasEpochMarker) { await stepStore.startEpoch(args.conversationId, { reason: "initial", modelProfile: "standard", @@ -368,6 +435,21 @@ export async function commitMessages(args: { provenance: nextProvenance[index]!, })), }); + } else if (!existing.modelId) { + await stepStore.startEpoch(args.conversationId, { + reason: "model_change", + modelProfile: existing.modelProfile, + modelId: args.modelId, + messages: args.messages.map((message, index) => ({ + message, + createdAtMs: messageTimestamp(message), + provenance: nextProvenance[index]!, + })), + }); + } else if (existing.modelId !== args.modelId) { + throw new Error( + `Cannot commit model ${args.modelId} into epoch bound to ${existing.modelId}`, + ); } else if (matchingPrefix === existing.messages.length) { const newMessages = args.messages.slice(matchingPrefix); await stepStore.append( diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index 494219109..4df2a4f02 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -3,6 +3,7 @@ import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAgentSteps, juniorConversationMessages, + juniorConversationTurns, juniorConversations, juniorDestinations, } from "@/db/schema"; @@ -86,6 +87,10 @@ export async function selectExpiredRoots( select 1 from junior_conversation_messages messages where messages.conversation_id = tree.conversation_id ) + or exists ( + select 1 from junior_conversation_turns turns + where turns.conversation_id = tree.conversation_id + ) or ( ${juniorDestinations.visibility} is distinct from 'public' and exists ( @@ -176,6 +181,10 @@ export async function purgeConversationTree( } } const ids = await conversationTreeIds(executor, args.rootConversationId); + await executor + .db() + .delete(juniorConversationTurns) + .where(inArray(juniorConversationTurns.conversationId, ids)); await executor .db() .delete(juniorAgentSteps) diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index a47080a66..69cb6750c 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -1,10 +1,13 @@ import { randomUUID } from "node:crypto"; import type { Destination } from "@sentry/junior-plugin-api"; import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import { parseDestination, sameDestination } from "@/chat/destination"; import { upsertIdentity } from "@/chat/identities/sql"; import type { IdentityUpsert } from "@/chat/identities/identity"; +import { modelProfileSchema } from "@/chat/model-profile"; +import { parseTurnReasoningLevel } from "@/chat/reasoning-level"; import type { StoredSlackActor } from "@/chat/actor"; import type { JuniorSqlDatabase } from "@/db/db"; import type { @@ -14,6 +17,12 @@ import type { ConversationStatus, ConversationStore, } from "../store"; +import { + conversationExecutionProfileSchema, + type ConversationExecutionProfile, + type ConversationExecutionProfileStore, +} from "../execution-profile"; +import { ensureConversationRow } from "./conversation-row"; import { juniorConversations, juniorDestinations, @@ -47,6 +56,70 @@ interface DestinationUpsert { } const CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation"; +const executionInstructionArraySchema = z.array( + z + .string() + .min(1) + .refine((value) => value.trim() === value), +); +const executionToolNameArraySchema = z.array(z.string().min(1)); + +interface ExecutionProfileRow { + allowedToolNames: string[] | null; + instructions: string[]; + modelProfile: string | null; + reasoningLevel: string | null; +} + +function executionProfileFromRow( + row: ExecutionProfileRow, +): ConversationExecutionProfile { + let reasoning: ConversationExecutionProfile["reasoning"] = { + mode: "adaptive", + }; + if (row.reasoningLevel !== null) { + reasoning = { + mode: "fixed", + level: parseTurnReasoningLevel(row.reasoningLevel), + }; + } + + let toolPolicy: ConversationExecutionProfile["toolPolicy"] = { + mode: "host", + }; + if (row.allowedToolNames !== null) { + toolPolicy = { + mode: "allowlist", + toolNames: executionToolNameArraySchema.parse(row.allowedToolNames), + }; + } + + return { + modelProfile: modelProfileSchema.parse(row.modelProfile), + reasoning, + instructions: executionInstructionArraySchema.parse(row.instructions), + toolPolicy, + }; +} + +function executionProfileColumns(profile: ConversationExecutionProfile) { + let reasoningLevel: string | null = null; + if (profile.reasoning.mode === "fixed") { + reasoningLevel = profile.reasoning.level; + } + + let allowedToolNames: string[] | null = null; + if (profile.toolPolicy.mode === "allowlist") { + allowedToolNames = profile.toolPolicy.toolNames; + } + + return { + executionAllowedToolNames: allowedToolNames, + executionInstructions: profile.instructions, + executionModelProfile: profile.modelProfile, + executionReasoningLevel: reasoningLevel, + }; +} function now(): number { return Date.now(); @@ -456,7 +529,9 @@ function updateConversationUsage(args: { return usage; } -export class SqlStore implements ConversationStore { +export class SqlStore + implements ConversationStore, ConversationExecutionProfileStore +{ constructor(private readonly executor: JuniorSqlDatabase) {} async get(args: { @@ -469,6 +544,46 @@ export class SqlStore implements ConversationStore { return conversationFromRow(row); } + async getOrCreateExecutionProfile(args: { + conversationId: string; + profile: ConversationExecutionProfile; + nowMs?: number; + }): Promise { + const profile = conversationExecutionProfileSchema.parse(args.profile); + return await this.withConversationMutation( + args.conversationId, + async () => { + const rows = await this.executor + .db() + .select({ + allowedToolNames: juniorConversations.executionAllowedToolNames, + instructions: juniorConversations.executionInstructions, + modelProfile: juniorConversations.executionModelProfile, + reasoningLevel: juniorConversations.executionReasoningLevel, + }) + .from(juniorConversations) + .where(eq(juniorConversations.conversationId, args.conversationId)); + const existing = rows[0]; + if (existing && existing.modelProfile !== null) { + return executionProfileFromRow(existing); + } + if (rows.length === 0) { + await ensureConversationRow( + this.executor, + args.conversationId, + args.nowMs ?? now(), + ); + } + await this.executor + .db() + .update(juniorConversations) + .set(executionProfileColumns(profile)) + .where(eq(juniorConversations.conversationId, args.conversationId)); + return profile; + }, + ); + } + async recordActivity(args: { activityAtMs?: number; channelName?: string; diff --git a/packages/junior/src/chat/conversations/sql/turns.ts b/packages/junior/src/chat/conversations/sql/turns.ts new file mode 100644 index 000000000..2b923fcd2 --- /dev/null +++ b/packages/junior/src/chat/conversations/sql/turns.ts @@ -0,0 +1,145 @@ +import { and, eq } from "drizzle-orm"; +import type { JuniorSqlDatabase } from "@/db/db"; +import { juniorAgentSteps, juniorConversationTurns } from "@/db/schema"; +import { agentStepEntrySchema } from "../history"; +import { + conversationTurnSchema, + newConversationTurnSchema, + type ConversationTurn, + type ConversationTurnStore, + type NewConversationTurn, +} from "../turns"; + +type ConversationTurnRow = typeof juniorConversationTurns.$inferSelect; + +function turnFromRow( + row: ConversationTurnRow, + startingModelId: string, +): ConversationTurn { + return conversationTurnSchema.parse({ + conversationId: row.conversationId, + turnId: row.turnId, + startingModelId, + startingSeq: row.startingSeq, + }); +} + +class SqlConversationTurnStore implements ConversationTurnStore { + constructor(private readonly executor: JuniorSqlDatabase) {} + + private async materialize( + row: ConversationTurnRow, + ): Promise { + const boundaries = await this.executor + .db() + .select({ contextEpoch: juniorAgentSteps.contextEpoch }) + .from(juniorAgentSteps) + .where( + and( + eq(juniorAgentSteps.conversationId, row.conversationId), + eq(juniorAgentSteps.seq, row.startingSeq), + ), + ) + .limit(1); + const boundary = boundaries[0]; + if (!boundary) { + throw new Error("Conversation turn starting step does not exist"); + } + const markers = await this.executor + .db() + .select({ payload: juniorAgentSteps.payload }) + .from(juniorAgentSteps) + .where( + and( + eq(juniorAgentSteps.conversationId, row.conversationId), + eq(juniorAgentSteps.contextEpoch, boundary.contextEpoch), + eq(juniorAgentSteps.type, "context_epoch_started"), + ), + ) + .limit(1); + const marker = markers[0]; + if (!marker) { + throw new Error("Conversation turn starting epoch is unbound"); + } + const entry = agentStepEntrySchema.parse({ + type: "context_epoch_started", + ...marker.payload, + }); + if (entry.type !== "context_epoch_started" || !entry.modelId) { + throw new Error("Conversation turn starting epoch has no model binding"); + } + return turnFromRow(row, entry.modelId); + } + + async recordStart(input: NewConversationTurn): Promise { + const turn = newConversationTurnSchema.parse(input); + const existing = await this.executor + .db() + .select() + .from(juniorConversationTurns) + .where( + and( + eq(juniorConversationTurns.conversationId, turn.conversationId), + eq(juniorConversationTurns.turnId, turn.turnId), + ), + ) + .limit(1); + if (existing[0]) { + return this.materialize(existing[0]); + } + await this.materialize({ + conversationId: turn.conversationId, + turnId: turn.turnId, + startingSeq: turn.startingSeq, + }); + await this.executor + .db() + .insert(juniorConversationTurns) + .values({ + conversationId: turn.conversationId, + turnId: turn.turnId, + startingSeq: turn.startingSeq, + }) + .onConflictDoNothing(); + const stored = await this.executor + .db() + .select() + .from(juniorConversationTurns) + .where( + and( + eq(juniorConversationTurns.conversationId, turn.conversationId), + eq(juniorConversationTurns.turnId, turn.turnId), + ), + ) + .limit(1); + if (!stored[0]) { + throw new Error("Conversation turn start was not persisted"); + } + return this.materialize(stored[0]); + } + + async get( + conversationId: string, + turnId: string, + ): Promise { + const rows = await this.executor + .db() + .select() + .from(juniorConversationTurns) + .where( + and( + eq(juniorConversationTurns.conversationId, conversationId), + eq(juniorConversationTurns.turnId, turnId), + ), + ) + .limit(1); + return rows[0] ? this.materialize(rows[0]) : undefined; + } +} + +/** Create a SQL-backed durable conversation turn store. */ +export function createSqlConversationTurnStore( + executor: JuniorSqlDatabase, +): ConversationTurnStore { + return new SqlConversationTurnStore(executor); +} diff --git a/packages/junior/src/chat/conversations/turns.ts b/packages/junior/src/chat/conversations/turns.ts new file mode 100644 index 000000000..6a83827a4 --- /dev/null +++ b/packages/junior/src/chat/conversations/turns.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const newConversationTurnSchema = z + .object({ + conversationId: z.string().min(1), + turnId: z.string().min(1), + startingSeq: z.number().int().nonnegative(), + }) + .strict(); + +export type NewConversationTurn = z.output; + +export const conversationTurnSchema = newConversationTurnSchema.extend({ + startingModelId: z.string().min(1), +}); + +export type ConversationTurn = z.output; + +/** Persist and read durable conversation turn boundaries. */ +export interface ConversationTurnStore { + /** Record or return a turn start without moving an existing boundary. */ + recordStart(turn: NewConversationTurn): Promise; + /** Read one durable turn by its conversation-scoped identity. */ + get( + conversationId: string, + turnId: string, + ): Promise; +} diff --git a/packages/junior/src/chat/db.ts b/packages/junior/src/chat/db.ts index ea1f73f9f..eb5a032a0 100644 --- a/packages/junior/src/chat/db.ts +++ b/packages/junior/src/chat/db.ts @@ -1,10 +1,13 @@ import { getChatConfig, type SqlDriver } from "@/chat/config"; import { createSqlStore } from "@/chat/conversations/sql/store"; import type { ConversationStore } from "@/chat/conversations/store"; +import type { ConversationExecutionProfileStore } from "@/chat/conversations/execution-profile"; import { createSqlAgentStepStore } from "@/chat/conversations/sql/history"; import type { AgentStepStore } from "@/chat/conversations/history"; import { createSqlConversationMessageStore } from "@/chat/conversations/sql/messages"; import type { ConversationMessageStore } from "@/chat/conversations/messages"; +import { createSqlConversationTurnStore } from "@/chat/conversations/sql/turns"; +import type { ConversationTurnStore } from "@/chat/conversations/turns"; import { createSqlConversationSearchStore } from "@/chat/conversations/sql/search"; import type { ConversationSearchStore } from "@/chat/conversations/search"; import type { JuniorDatabase, JuniorSqlExecutor } from "@/db/db"; @@ -15,9 +18,10 @@ let current: databaseUrl: string; db: JuniorSqlExecutor; driver: SqlDriver; - store: ConversationStore; + store: ConversationStore & ConversationExecutionProfileStore; stepStore: AgentStepStore; messageStore: ConversationMessageStore; + turnStore: ConversationTurnStore; searchStore: ConversationSearchStore; } | undefined; @@ -58,6 +62,7 @@ export function getSqlExecutor(): JuniorSqlExecutor { store: createSqlStore(db), stepStore: createSqlAgentStepStore(db), messageStore: createSqlConversationMessageStore(db), + turnStore: createSqlConversationTurnStore(db), searchStore: createSqlConversationSearchStore(db), }; } @@ -75,6 +80,12 @@ export function getConversationStore(): ConversationStore { return current!.store; } +/** Return the SQL-backed durable execution-profile store. */ +export function getConversationExecutionProfileStore(): ConversationExecutionProfileStore { + getSqlExecutor(); + return current!.store; +} + /** Return the SQL-backed durable agent step store. */ export function getAgentStepStore(): AgentStepStore { getSqlExecutor(); @@ -87,6 +98,12 @@ export function getConversationMessageStore(): ConversationMessageStore { return current!.messageStore; } +/** Return the SQL-backed durable conversation turn store. */ +export function getConversationTurnStore(): ConversationTurnStore { + getSqlExecutor(); + return current!.turnStore; +} + /** Return the SQL-backed public provider-tenant conversation search store. */ export function getConversationSearchStore(): ConversationSearchStore { getSqlExecutor(); diff --git a/packages/junior/src/chat/runtime/README.md b/packages/junior/src/chat/runtime/README.md index 7b304a171..c481560d0 100644 --- a/packages/junior/src/chat/runtime/README.md +++ b/packages/junior/src/chat/runtime/README.md @@ -27,6 +27,23 @@ this directory owns product orchestration around it. callbacks append new work and start a later run. - Completion and delivery markers make retries idempotent. +## Conversation Runtime + +- Every production agent entry point runs through the shared conversation + runtime before entering the common `AgentRunner` and Pi kernel. +- A canonical `conversationId` loads or atomically materializes the immutable + execution profile used by every slice of that conversation. +- The execution profile is conversation-owned configuration for model profile, + reasoning, additional instructions, and tool restrictions. +- Profile tool policy can narrow host authority but cannot grant tools, + credentials, destinations, or other capabilities. +- Durable turn rows anchor each user request to its starting execution-log + boundary. Reporting derives the starting model from the bound context epoch. +- Durable context epochs remain authoritative after model handoff; loading the + execution profile must not roll the conversation back to its initial model. +- A resumed slice uses its epoch's exact model. Host catalog remapping is + applied only at a fresh-turn boundary by opening a `model_change` epoch. + ## Prompt Ownership - Core prompt text contains stable Junior behavior, not provider-specific setup. diff --git a/packages/junior/src/chat/runtime/agent-continue-runner.ts b/packages/junior/src/chat/runtime/agent-continue-runner.ts index 7be1a6df4..4c5ee4e52 100644 --- a/packages/junior/src/chat/runtime/agent-continue-runner.ts +++ b/packages/junior/src/chat/runtime/agent-continue-runner.ts @@ -5,7 +5,6 @@ * drop stale callbacks before generation, while any started continuation must * durably record success, failure, auth pause, or another safe pause boundary. */ -import { botConfig } from "@/chat/config"; import { buildTurnFailureResponse, logException, @@ -72,10 +71,7 @@ import { clearPendingAuth } from "@/chat/services/pending-auth"; import { requireSlackDestination } from "@/chat/destination"; import type { CredentialContext } from "@/chat/credentials/context"; import { sleep } from "@/chat/sleep"; -import { - modelIdForProfile, - STANDARD_MODEL_PROFILE, -} from "@/chat/model-profile"; +import { STANDARD_MODEL_PROFILE } from "@/chat/model-profile"; import { retainRuntimeTurnContext, stripRuntimeTurnContext, @@ -590,7 +586,10 @@ async function recoverStrandedRunningSession(args: { conversationId: args.conversationId, }); const modelProfile = recoveryProjection.modelProfile; - const modelId = modelIdForProfile(botConfig, modelProfile); + const modelId = recoveryProjection.modelId ?? sessionRecord.modelId; + if (!modelId) { + throw new Error("Recovered context epoch has no model binding"); + } const recoveryMessages = modelProfile !== STANDARD_MODEL_PROFILE ? [ diff --git a/packages/junior/src/chat/runtime/conversation-runtime.ts b/packages/junior/src/chat/runtime/conversation-runtime.ts new file mode 100644 index 000000000..61ab21baa --- /dev/null +++ b/packages/junior/src/chat/runtime/conversation-runtime.ts @@ -0,0 +1,119 @@ +/** + * Materializes immutable conversation execution defaults before delegating to + * the agent kernel. Caller authority remains intact: profile tools only narrow + * host restrictions, while durable model and reasoning choices own continuity. + */ +import type { AgentRunRequest } from "@/chat/agent/request"; +import { assertCorrelationDestinationMatch } from "@/chat/agent/request"; +import type { + ConversationExecutionProfile, + ConversationExecutionProfileStore, + ConversationToolPolicy, +} from "@/chat/conversations/execution-profile"; +import type { AgentRunner } from "@/chat/runtime/agent-runner"; +import { parseSlackThreadId } from "@/chat/slack/context"; + +/** Dependencies for durable conversation execution-profile materialization. */ +export interface ConversationRuntimeOptions { + agentRunner: AgentRunner; + defaultProfile: ConversationExecutionProfile; + profileStore: ConversationExecutionProfileStore; +} + +/** Intersect durable tool policy with the caller's host-owned restriction. */ +function restrictToolPolicy( + hostPolicy: ConversationToolPolicy | undefined, + profilePolicy: ConversationToolPolicy, +): ConversationToolPolicy { + if (hostPolicy?.mode !== "allowlist") { + return profilePolicy; + } + if (profilePolicy.mode === "host") { + return hostPolicy; + } + return { + mode: "allowlist", + toolNames: hostPolicy.toolNames.filter((name) => + profilePolicy.toolNames.includes(name), + ), + }; +} + +/** Require one canonical conversation identity across routing coordinates. */ +function conversationIdForProfile(request: AgentRunRequest): string { + const { correlation, destination, source } = request.routing; + const conversationId = correlation?.conversationId; + if (!conversationId) { + throw new TypeError("Conversation runtime requires a conversationId"); + } + if (request.routing.dispatch) { + if ( + correlation.threadId !== undefined && + correlation.threadId !== conversationId + ) { + throw new TypeError( + "Dispatch conversation routing identity does not match", + ); + } + return conversationId; + } + if (destination.platform === "local") { + if ( + source.platform !== "local" || + destination.conversationId !== conversationId || + source.conversationId !== conversationId + ) { + throw new TypeError("Local conversation routing identity does not match"); + } + return conversationId; + } + if (source.platform !== "slack") { + throw new TypeError("Slack conversation routing source does not match"); + } + const thread = parseSlackThreadId(conversationId); + const sourceThreadTs = source.threadTs ?? source.messageTs; + if ( + !thread || + thread.channelId !== destination.channelId || + source.channelId !== destination.channelId || + source.teamId !== destination.teamId || + sourceThreadTs !== thread.threadTs || + (correlation.threadId !== undefined && + correlation.threadId !== conversationId) + ) { + throw new TypeError("Slack conversation routing identity does not match"); + } + return conversationId; +} + +/** Run agents from durable conversation behavior rather than caller defaults. */ +export function createConversationRuntime( + options: ConversationRuntimeOptions, +): AgentRunner { + return { + run: async (request: AgentRunRequest) => { + assertCorrelationDestinationMatch(request.routing); + const conversationId = conversationIdForProfile(request); + const profile = await options.profileStore.getOrCreateExecutionProfile({ + conversationId, + profile: options.defaultProfile, + }); + return await options.agentRunner.run({ + ...request, + policy: { + ...request.policy, + modelProfile: profile.modelProfile, + reasoningPolicy: profile.reasoning, + instructions: [ + ...(request.policy?.instructions ?? []), + ...profile.instructions, + ], + toolPolicy: restrictToolPolicy( + request.policy?.toolPolicy, + profile.toolPolicy, + ), + }, + }); + }, + }; +} diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 044bf604a..4893578e3 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -132,7 +132,6 @@ import { import { requireSlackDestination } from "@/chat/destination"; import { escapeXml } from "@/chat/xml"; import { persistConversationMessages } from "@/chat/conversations/visible-messages"; -import { modelIdForProfile } from "@/chat/model-profile"; /** * Persist post-delivery Redis scratch with a short retry after durable SQL @@ -683,9 +682,12 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { // A prior delivery already appended this input durably. return true; } + if (!projection.modelId) { + throw new Error("Parked input epoch has no model binding"); + } await commitMessages({ conversationId, - modelId: modelIdForProfile(botConfig, projection.modelProfile), + modelId: projection.modelId, messages: [ ...projection.messages, ...missing.map((pair) => pair.message), diff --git a/packages/junior/src/chat/services/context-compaction.ts b/packages/junior/src/chat/services/context-compaction.ts index 7ce85b5b1..5f9b1818c 100644 --- a/packages/junior/src/chat/services/context-compaction.ts +++ b/packages/junior/src/chat/services/context-compaction.ts @@ -35,7 +35,7 @@ import { trimTrailingAssistantMessages, } from "@/chat/pi/transcript"; import { updateConversationStats } from "@/chat/services/conversation-memory"; -import { modelIdForProfile, type ModelProfile } from "@/chat/model-profile"; +import type { ModelProfile } from "@/chat/model-profile"; const RETAINED_USER_MESSAGE_TOKENS = 20_000; const MAX_SUMMARY_INPUT_CHARS = 80_000; @@ -476,10 +476,13 @@ async function writeCompactedThreadContext( retained, sourceProvenance: sourceProjection.provenance, }); + if (!sourceProjection.modelId) { + throw new Error("Compaction source epoch has no model binding"); + } await stepStore.startEpoch(args.conversationId, { reason: "compaction", modelProfile: sourceProjection.modelProfile, - modelId: modelIdForProfile(botConfig, sourceProjection.modelProfile), + modelId: sourceProjection.modelId, messages: replacement.map((message, index) => ({ message, createdAtMs: piMessageTimestamp(message), diff --git a/packages/junior/src/chat/services/turn-reasoning-level.ts b/packages/junior/src/chat/services/turn-reasoning-level.ts index 702eee60e..8f1d3f3c9 100644 --- a/packages/junior/src/chat/services/turn-reasoning-level.ts +++ b/packages/junior/src/chat/services/turn-reasoning-level.ts @@ -148,7 +148,7 @@ function buildClassifierPrompt(args: { /** Preserve an explicitly configured reasoning level without invoking the router. */ export function configuredTurnReasoningLevel( reasoningLevel: TurnReasoningLevel, - source: "agent_config" | "default", + source: "agent_config" | "default" | "resume", ): TurnReasoningSelection { return { reasoningLevel, diff --git a/packages/junior/src/chat/services/turn-result.ts b/packages/junior/src/chat/services/turn-result.ts index 6a12e433f..8b7496223 100644 --- a/packages/junior/src/chat/services/turn-result.ts +++ b/packages/junior/src/chat/services/turn-result.ts @@ -107,6 +107,7 @@ export interface AgentTurnDiagnostics { durationMs?: number; errorMessage?: string; providerError?: unknown; + startingModelId?: string; modelId: string; outcome: "success" | "execution_failure" | "provider_error"; reasoningLevel?: TurnReasoningSelection["reasoningLevel"]; @@ -150,6 +151,7 @@ export interface TurnResultInput { runId?: string; }; assistantUserName?: string; + startingModelId?: string; modelId: string; } @@ -212,8 +214,10 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { reasoningSelection, correlation, assistantUserName, + startingModelId, modelId, } = input; + const resolvedStartingModelId = startingModelId ?? modelId; const toolResults = newMessages.filter(isToolResultMessage); const assistantMessages = newMessages.filter(isAssistantMessage); @@ -376,6 +380,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { const resolvedDiagnostics: AgentTurnDiagnostics = { outcome: resolvedOutcome, + startingModelId: resolvedStartingModelId, modelId, assistantMessageCount: assistantMessages.length, reasoningLevel: reasoningSelection.reasoningLevel, diff --git a/packages/junior/src/chat/state/turn-session.ts b/packages/junior/src/chat/state/turn-session.ts index c7fcf2037..928f39ac9 100644 --- a/packages/junior/src/chat/state/turn-session.ts +++ b/packages/junior/src/chat/state/turn-session.ts @@ -65,6 +65,7 @@ export interface AgentTurnSessionRecord { errorMessage?: string; lastProgressAtMs: number; loadedSkillNames?: string[]; + /** Model active at the latest persisted boundary. */ modelId?: string; reasoningLevel?: string; piMessages: PiMessage[]; diff --git a/packages/junior/src/chat/task-execution/README.md b/packages/junior/src/chat/task-execution/README.md index 9b111082c..c5abee963 100644 --- a/packages/junior/src/chat/task-execution/README.md +++ b/packages/junior/src/chat/task-execution/README.md @@ -13,6 +13,8 @@ source of truth. - Check-ins extend active ownership and allow heartbeat recovery to distinguish slow work from abandoned work. - Delivery state prevents a completed turn from being posted twice. +- The immutable conversation execution profile is stored with conversation + metadata, not in mailbox, lease, queue, or session-log state. `state.ts`, `store.ts`, and their runtime schemas define the persisted shapes. diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index fdd9cf51b..221df7b8b 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -20,8 +20,6 @@ import type { LocalAgentTurnDeps, LocalToolResult, } from "@/chat/local/runner"; -import { executeAgentRun } from "@/chat/agent"; -import { createAgentRunner } from "@/chat/runtime/agent-runner"; import type { JuniorPluginSet } from "@/plugins"; export const CHAT_USAGE = "usage: junior chat\n junior chat -p "; @@ -232,9 +230,29 @@ async function prepareLocalChatRun( ) { defaultStateAdapterForLocalChat(); await configureLocalChatPlugins(pluginSet); - const { runLocalAgentTurn } = await import("@/chat/local/runner"); + const [ + { executeAgentRun }, + { botConfig }, + { defaultConversationExecutionProfile }, + { getConversationExecutionProfileStore }, + { runLocalAgentTurn }, + { createAgentRunner }, + { createConversationRuntime }, + ] = await Promise.all([ + import("@/chat/agent"), + import("@/chat/config"), + import("@/chat/conversations/execution-profile"), + import("@/chat/db"), + import("@/chat/local/runner"), + import("@/chat/runtime/agent-runner"), + import("@/chat/runtime/conversation-runtime"), + ]); const deps: LocalAgentTurnDeps = { - agentRunner: createAgentRunner(executeAgentRun), + agentRunner: createConversationRuntime({ + agentRunner: createAgentRunner(executeAgentRun), + defaultProfile: defaultConversationExecutionProfile(botConfig), + profileStore: getConversationExecutionProfileStore(), + }), deliverReply: async (reply) => { await deliverReply(io, reply); }, diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index 341c2d225..04805dad0 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -1,5 +1,6 @@ import { juniorAgentSteps } from "./schema/agent-steps"; import { juniorConversationMessages } from "./schema/conversation-messages"; +import { juniorConversationTurns } from "./schema/conversation-turns"; import { juniorConversations } from "./schema/conversations"; import { juniorDestinations } from "./schema/destinations"; import { juniorIdentities } from "./schema/identities"; @@ -8,6 +9,7 @@ import { juniorUsers } from "./schema/users"; export { juniorAgentSteps, juniorConversationMessages, + juniorConversationTurns, juniorConversations, juniorDestinations, juniorIdentities, @@ -17,6 +19,7 @@ export { export const juniorSqlSchema = { juniorAgentSteps, juniorConversationMessages, + juniorConversationTurns, juniorConversations, juniorDestinations, juniorIdentities, diff --git a/packages/junior/src/db/schema/conversation-turns.ts b/packages/junior/src/db/schema/conversation-turns.ts new file mode 100644 index 000000000..2d591356c --- /dev/null +++ b/packages/junior/src/db/schema/conversation-turns.ts @@ -0,0 +1,33 @@ +import { + foreignKey, + integer, + pgTable, + primaryKey, + text, +} from "drizzle-orm/pg-core"; +import { juniorAgentSteps } from "./agent-steps"; +import { juniorConversations } from "./conversations"; + +/** + * Durable user-request boundary within a conversation. Model identity remains + * owned by the context-epoch markers in `junior_agent_steps`; `starting_seq` + * anchors the turn before any of its execution steps are appended. + */ +export const juniorConversationTurns = pgTable( + "junior_conversation_turns", + { + conversationId: text("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + turnId: text("turn_id").notNull(), + startingSeq: integer("starting_seq").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationId, table.turnId] }), + foreignKey({ + columns: [table.conversationId, table.startingSeq], + foreignColumns: [juniorAgentSteps.conversationId, juniorAgentSteps.seq], + name: "junior_conversation_turns_starting_step_fk", + }), + ], +); diff --git a/packages/junior/src/db/schema/conversations.ts b/packages/junior/src/db/schema/conversations.ts index 0f41d77a0..f7226e567 100644 --- a/packages/junior/src/db/schema/conversations.ts +++ b/packages/junior/src/db/schema/conversations.ts @@ -64,6 +64,13 @@ export const juniorConversations = pgTable( executionDurationMs: integer("execution_duration_ms").notNull().default(0), executionUsage: jsonb("execution_usage_json").$type(), metricRunId: text("metric_run_id"), + executionModelProfile: text("execution_model_profile"), + executionReasoningLevel: text("execution_reasoning_level"), + executionInstructions: text("execution_instructions") + .array() + .notNull() + .default(sql`'{}'::text[]`), + executionAllowedToolNames: text("execution_allowed_tool_names").array(), }, (table) => [ index("junior_conversations_last_activity_idx").on( diff --git a/packages/junior/tests/component/cli/chat-cli.test.ts b/packages/junior/tests/component/cli/chat-cli.test.ts index b90ef60bc..9c16be976 100644 --- a/packages/junior/tests/component/cli/chat-cli.test.ts +++ b/packages/junior/tests/component/cli/chat-cli.test.ts @@ -17,6 +17,9 @@ const testDb = vi.hoisted(() => ({ })); const db = vi.hoisted(() => ({ getDb: vi.fn(() => testDb), + getConversationExecutionProfileStore: vi.fn(() => ({ + getOrCreateExecutionProfile: vi.fn(), + })), })); vi.mock("@/chat/local/runner", () => ({ @@ -25,6 +28,7 @@ vi.mock("@/chat/local/runner", () => ({ vi.mock("@/chat/db", () => ({ getDb: db.getDb, + getConversationExecutionProfileStore: db.getConversationExecutionProfileStore, })); const ORIGINAL_STATE_ADAPTER = process.env.JUNIOR_STATE_ADAPTER; diff --git a/packages/junior/tests/component/conversation-transcripts-sql.test.ts b/packages/junior/tests/component/conversation-transcripts-sql.test.ts index 823f7544a..7ff765f29 100644 --- a/packages/junior/tests/component/conversation-transcripts-sql.test.ts +++ b/packages/junior/tests/component/conversation-transcripts-sql.test.ts @@ -5,6 +5,7 @@ import { agentStepEntrySchema } from "@/chat/conversations/history"; import { getAgentStepStore } from "@/chat/db"; import { purgeConversation } from "@/chat/conversations/retention"; import { createSqlConversationMessageStore } from "@/chat/conversations/sql/messages"; +import { createSqlConversationTurnStore } from "@/chat/conversations/sql/turns"; import { hydrateConversationCompactions, persistConversationCompactions, @@ -19,6 +20,7 @@ import { type LocalJuniorSqlFixture, } from "../fixtures/sql"; import { + commitMessages, loadConnectedMcpProviders, openConversationProjection, recordMcpProviderConnected, @@ -35,6 +37,14 @@ it("accepts legacy markers and validates current profile names", () => { modelProfile: "standard", }).success, ).toBe(false); + expect( + agentStepEntrySchema.safeParse({ + type: "context_epoch_started", + reason: "initial", + modelProfile: "coding", + modelId: "openai/gpt-5.4", + }).success, + ).toBe(true); expect( agentStepEntrySchema.safeParse({ type: "context_epoch_started", @@ -64,6 +74,21 @@ it("accepts legacy markers and validates current profile names", () => { modelId: "openai/gpt-5.4", }).success, ).toBe(false); + expect( + agentStepEntrySchema.safeParse({ + type: "context_epoch_started", + reason: "model_change", + modelProfile: "coding", + modelId: "openai/gpt-5.4", + }).success, + ).toBe(true); + expect( + agentStepEntrySchema.safeParse({ + type: "context_epoch_started", + reason: "model_change", + modelProfile: "coding", + }).success, + ).toBe(false); expect( agentStepEntrySchema.safeParse({ type: "context_epoch_started", @@ -101,7 +126,6 @@ it("accepts legacy markers and validates current profile names", () => { }).success, ).toBe(false); }); - it("rejects epoch markers through the ordinary append boundary", async () => { await expect( getAgentStepStore().append("local:test:invalid-marker-append", [ @@ -137,7 +161,9 @@ it("opens an explicit initial epoch without dropping earlier host facts", async await expect( openConversationProjection({ conversationId, - modelId: "openai/gpt-5.4", + initialModelProfile: "standard", + refreshModelBinding: true, + resolveModelId: () => "openai/gpt-5.4", }), ).resolves.toMatchObject({ messages: [], @@ -164,6 +190,110 @@ it("opens an explicit initial epoch without dropping earlier host facts", async ]); }); +it("opens a new epoch when a profile resolves to a new model", async () => { + const conversationId = "local:test:model-profile-remap"; + const first = await openConversationProjection({ + conversationId, + initialModelProfile: "coding", + refreshModelBinding: true, + resolveModelId: () => "openai/gpt-5.4", + }); + expect(first.modelId).toBe("openai/gpt-5.4"); + + const changed = await openConversationProjection({ + conversationId, + initialModelProfile: "standard", + refreshModelBinding: true, + resolveModelId: (profile) => + profile === "coding" ? "openai/gpt-5.6" : "openai/gpt-5.5", + }); + expect(changed).toMatchObject({ + modelProfile: "coding", + modelId: "openai/gpt-5.6", + }); + + const resumed = await openConversationProjection({ + conversationId, + initialModelProfile: "standard", + refreshModelBinding: false, + resolveModelId: () => "openai/gpt-5.7", + }); + expect(resumed.modelId).toBe("openai/gpt-5.6"); + expect( + (await getAgentStepStore().loadHistory(conversationId)) + .map((step) => step.entry) + .filter((entry) => entry.type === "context_epoch_started"), + ).toEqual([ + { + type: "context_epoch_started", + reason: "initial", + modelProfile: "coding", + modelId: "openai/gpt-5.4", + }, + { + type: "context_epoch_started", + reason: "model_change", + modelProfile: "coding", + modelId: "openai/gpt-5.6", + }, + ]); +}); + +it("binds unmarked message history to the conversation profile", async () => { + const conversationId = "local:test:legacy-unbound-profile"; + await getAgentStepStore().append(conversationId, [ + { + entry: { + type: "pi_message", + message: userMessage("legacy question"), + }, + createdAtMs: 1, + }, + ]); + + const opened = await openConversationProjection({ + conversationId, + initialModelProfile: "coding", + refreshModelBinding: true, + resolveModelId: () => "openai/gpt-5.6", + }); + expect(opened).toMatchObject({ + modelProfile: "coding", + modelId: "openai/gpt-5.6", + messages: [userMessage("legacy question")], + }); + expect( + (await getAgentStepStore().loadHistory(conversationId)) + .map((step) => step.entry) + .filter((entry) => entry.type === "context_epoch_started"), + ).toEqual([ + { + type: "context_epoch_started", + reason: "model_change", + modelProfile: "coding", + modelId: "openai/gpt-5.6", + }, + ]); +}); + +it("rejects writes from a model that does not own the current epoch", async () => { + const conversationId = "local:test:wrong-model-commit"; + await openConversationProjection({ + conversationId, + initialModelProfile: "standard", + refreshModelBinding: true, + resolveModelId: () => "openai/gpt-5.5", + }); + + await expect( + commitMessages({ + conversationId, + modelId: "openai/gpt-5.6", + messages: [userMessage("wrong model")], + }), + ).rejects.toThrow("epoch bound to openai/gpt-5.5"); +}); + async function seedConversation( fixture: LocalJuniorSqlFixture, conversationId: string, @@ -229,7 +359,7 @@ describe("conversation transcript SQL stores", () => { const [applied] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", ); - expect(applied?.count).toBe(4); + expect(applied?.count).toBe(6); } finally { await fixture.close(); } @@ -288,6 +418,59 @@ describe("conversation transcript SQL stores", () => { } }); + it("derives turn models from their epoch-bound starting steps", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateSchema(fixture.sql); + await seedConversation(fixture, CONVERSATION_ID); + const steps = createSqlAgentStepStore(fixture.sql); + const turns = createSqlConversationTurnStore(fixture.sql); + await steps.startEpoch(CONVERSATION_ID, { + reason: "initial", + modelProfile: "standard", + modelId: "openai/gpt-5.5", + messages: [], + }); + const first = await turns.recordStart({ + conversationId: CONVERSATION_ID, + turnId: "turn-a", + startingSeq: 0, + }); + await steps.startEpoch(CONVERSATION_ID, { + reason: "model_change", + modelProfile: "standard", + modelId: "openai/gpt-5.6", + messages: [], + }); + await turns.recordStart({ + conversationId: CONVERSATION_ID, + turnId: "turn-b", + startingSeq: 1, + }); + + expect(first.startingModelId).toBe("openai/gpt-5.5"); + await expect( + turns.recordStart({ + conversationId: CONVERSATION_ID, + turnId: "turn-a", + startingSeq: 1, + }), + ).resolves.toMatchObject({ + startingModelId: "openai/gpt-5.5", + startingSeq: 0, + }); + await expect(turns.get(CONVERSATION_ID, "turn-b")).resolves.toMatchObject( + { + turnId: "turn-b", + startingModelId: "openai/gpt-5.6", + }, + ); + } finally { + await fixture.close(); + } + }); + it("returns only the highest epoch from loadCurrentEpoch", async () => { const fixture = await createLocalJuniorSqlFixture(); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 752b11b52..6bcdb9fec 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -13,6 +13,7 @@ import { import { juniorAgentSteps, juniorConversationMessages, + juniorConversationTurns, juniorConversations, juniorDestinations, } from "@/db/schema"; @@ -96,6 +97,11 @@ async function seedConversation( text: "hi", createdAt: at, }); + await executor.db().insert(juniorConversationTurns).values({ + conversationId: args.conversationId, + turnId: "turn-1", + startingSeq: 0, + }); } } @@ -123,6 +129,18 @@ async function messageCount( return rows.length; } +async function turnCount( + executor: JuniorSqlDatabase, + conversationId: string, +): Promise { + const rows = await executor + .db() + .select() + .from(juniorConversationTurns) + .where(eq(juniorConversationTurns.conversationId, conversationId)); + return rows.length; +} + async function readConversation( executor: JuniorSqlDatabase, conversationId: string, @@ -179,6 +197,7 @@ describe("retention purge job", () => { }); expect(first.purged).toBe(1); expect(await stepCount(fixture.sql, "priv")).toBe(0); + expect(await turnCount(fixture.sql, "priv")).toBe(0); expect(await stepCount(fixture.sql, "pub")).toBe(1); expect( (await readConversation(fixture.sql, "pub")).transcriptPurgedAt, diff --git a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts index 1ef0c741d..db0d52766 100644 --- a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts @@ -72,7 +72,7 @@ describe("executeAgentRun error path", () => { it("preserves configured reasoning in failure diagnostics", async () => { const outcome = await executeAgentRun({ input: { messageText: "hello" }, - policy: { reasoningLevel: "high" }, + policy: { reasoningPolicy: { mode: "fixed", level: "high" } }, routing: { destination: LOCAL_DESTINATION, source: LOCAL_SOURCE }, }); const reply = outcome.status === "completed" ? outcome.result : undefined; diff --git a/packages/junior/tests/component/services/context-compaction.test.ts b/packages/junior/tests/component/services/context-compaction.test.ts index 7e1f862c2..fa8f35417 100644 --- a/packages/junior/tests/component/services/context-compaction.test.ts +++ b/packages/junior/tests/component/services/context-compaction.test.ts @@ -331,7 +331,7 @@ describe("context compaction projection reset", () => { ).toBe("handoff"); await commitMessages({ - modelId: "test/handoff", + modelId: botConfig.modelProfiles.handoff, conversationId, messages: [user("Replacement safe boundary.", 3)], }); @@ -368,7 +368,7 @@ describe("context compaction projection reset", () => { { reason: "rollback", modelProfile: "handoff", - modelId: "test/handoff", + modelId: botConfig.modelProfiles.handoff, }, ]); }); diff --git a/packages/junior/tests/component/services/turn-session-record.test.ts b/packages/junior/tests/component/services/turn-session-record.test.ts index e0dad1262..27c44abf9 100644 --- a/packages/junior/tests/component/services/turn-session-record.test.ts +++ b/packages/junior/tests/component/services/turn-session-record.test.ts @@ -326,7 +326,7 @@ describe("persistAuthPauseSessionRecord", () => { resumeReason: "timeout", }); await upsertAgentTurnSessionRecord({ - modelId: "test/model", + modelId: "test-model", conversationId: "conversation-actor-empty-commit", sessionId: "turn-actor-empty-commit", sliceId: 2, @@ -679,7 +679,7 @@ describe("persistAuthPauseSessionRecord", () => { await expect( persistTimeoutSessionRecord({ - modelId: "test-model", + modelId: "test/model", conversationId: "conversation-timeout-cap", sessionId: "turn-timeout-cap", currentSliceId: AGENT_CONTINUE_MAX_SLICES, @@ -1203,12 +1203,24 @@ describe("persistAuthPauseSessionRecord", () => { resumeReason: "timeout", piMessages: [userMessage("continue")], }); + const { getAgentStepStore } = await import("@/chat/db"); + await getAgentStepStore().startEpoch(conversationId, { + reason: "handoff", + modelProfile: "handoff", + modelId: "openai/gpt-5.6-sol", + messages: [ + { + message: userMessage("continue"), + createdAtMs: 1, + }, + ], + }); await upsertAgentTurnSessionRecord({ conversationId, sessionId, sliceId: 2, state: "running", - modelId: "openai/gpt-5.6", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", piMessages: [userMessage("continue")], }); @@ -1216,7 +1228,7 @@ describe("persistAuthPauseSessionRecord", () => { await expect( getAgentTurnSessionRecord(conversationId, sessionId), ).resolves.toMatchObject({ - modelId: "openai/gpt-5.6", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", }); expect( @@ -1224,7 +1236,7 @@ describe("persistAuthPauseSessionRecord", () => { (summary) => summary.sessionId === sessionId, ), ).toMatchObject({ - modelId: "openai/gpt-5.6", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", }); }); diff --git a/packages/junior/tests/integration/agent-continue-slack.test.ts b/packages/junior/tests/integration/agent-continue-slack.test.ts index b0a0374e8..5b70fdb60 100644 --- a/packages/junior/tests/integration/agent-continue-slack.test.ts +++ b/packages/junior/tests/integration/agent-continue-slack.test.ts @@ -145,7 +145,10 @@ describe("agent continuation Slack integration", () => { executeAgentRunMock.mockResolvedValue( completedAgentRun({ text: "Final resumed answer", - diagnostics: makeDiagnostics(), + diagnostics: { + ...makeDiagnostics(), + modelId: "test/model", + }, }), ); resetSlackApiMockState(); @@ -1196,7 +1199,10 @@ describe("agent continuation Slack integration", () => { timestamp: 2, }, ] as any, - diagnostics: makeDiagnostics(), + diagnostics: { + ...makeDiagnostics(), + modelId: "test/model", + }, }), ); await turnSessionStoreModule.upsertAgentTurnSessionRecord({ @@ -1417,7 +1423,7 @@ describe("agent continuation Slack integration", () => { ] as any, diagnostics: { ...makeDiagnostics(), - modelId: "openai/gpt-5.6-sol", + modelId: "test/model", }, }); }); @@ -1434,7 +1440,7 @@ describe("agent continuation Slack integration", () => { expect(resumed).toBe(true); expect(recoveredRecord).toMatchObject({ actors: [expect.objectContaining({ userId: "U123" })], - modelId: "openai/gpt-5.6-sol", + modelId: "test/model", piMessages: expect.arrayContaining([ expect.objectContaining({ role: "user" }), ]), diff --git a/packages/junior/tests/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index 482ed0251..69e9b1f9d 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -141,7 +141,7 @@ VALUES ('host-migration', 9999999999999) "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", ); expect(host?.count).toBe(1); - expect(core?.count).toBe(4); + expect(core?.count).toBe(5); } finally { await fixture.close(); } @@ -183,7 +183,11 @@ ALTER TABLE junior_conversations DROP COLUMN usage_json, DROP COLUMN execution_duration_ms, DROP COLUMN execution_usage_json, - DROP COLUMN metric_run_id + DROP COLUMN metric_run_id, + DROP COLUMN execution_model_profile, + DROP COLUMN execution_reasoning_level, + DROP COLUMN execution_instructions, + DROP COLUMN execution_allowed_tool_names `); await Promise.all([ fixture.sql.query("SELECT 1"), @@ -194,7 +198,7 @@ ALTER TABLE junior_conversations const [journal] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", ); - expect(journal?.count).toBe(4); + expect(journal?.count).toBe(5); } finally { await second.close(); await fixture.close(); @@ -262,7 +266,7 @@ WHERE conversation_id = $1 "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", ); - expect(migrationRows?.count).toBe(4); + expect(migrationRows?.count).toBe(5); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ conversation_id: "slack:C123:1718123456.000000", @@ -317,7 +321,11 @@ ALTER TABLE junior_conversations DROP COLUMN usage_json, DROP COLUMN execution_duration_ms, DROP COLUMN execution_usage_json, - DROP COLUMN metric_run_id + DROP COLUMN metric_run_id, + DROP COLUMN execution_model_profile, + DROP COLUMN execution_reasoning_level, + DROP COLUMN execution_instructions, + DROP COLUMN execution_allowed_tool_names `); await migrateSchema(fixture.sql); @@ -345,7 +353,7 @@ ORDER BY column_name "execution_usage_json", "usage_json", ]); - expect(migrationRows?.count).toBe(4); + expect(migrationRows?.count).toBe(5); } finally { await fixture.close(); } @@ -377,6 +385,15 @@ VALUES await fixture.sql.execute( "DROP INDEX junior_conversation_messages_search_idx", ); + await fixture.sql.execute( + ` +ALTER TABLE junior_conversations + DROP COLUMN execution_model_profile, + DROP COLUMN execution_reasoning_level, + DROP COLUMN execution_instructions, + DROP COLUMN execution_allowed_tool_names +`, + ); await migrateSchema(fixture.sql); @@ -399,7 +416,7 @@ WHERE table_schema = 'public' ) ORDER BY column_name `); - expect(migrationRows?.count).toBe(3); + expect(migrationRows?.count).toBe(4); expect(searchIndex?.exists).toBe(true); expect(metricColumns.map((row) => row.column_name)).toEqual([ "duration_ms", diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 6178785b7..7ebf9ee46 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -322,12 +322,32 @@ describe("dashboard reporting", () => { }); it("reports the complete SQL conversation transcript", async () => { - const { recordAgentTurnSessionSummary, upsertAgentTurnSessionRecord } = + const { upsertAgentTurnSessionRecord } = await import("@/chat/state/turn-session"); const { readConversationDetailFromSql } = await import("@/api/conversations/detail.query"); + const { getAgentStepStore, getConversationTurnStore } = + await import("@/chat/db"); await confirmPublicSlackConversation("slack:C1:222"); + const stepStore = getAgentStepStore(); + await stepStore.startEpoch("slack:C1:222", { + reason: "initial", + modelProfile: "standard", + modelId: "openai/gpt-5.5", + messages: [], + }); + await getConversationTurnStore().recordStart({ + conversationId: "slack:C1:222", + turnId: "turn-current", + startingSeq: 0, + }); + await stepStore.startEpoch("slack:C1:222", { + reason: "handoff", + modelProfile: "handoff", + modelId: "openai/gpt-5.6-sol", + messages: [], + }); await upsertAgentTurnSessionRecord({ conversationId: "slack:C1:222", sessionId: "turn-current", @@ -335,7 +355,7 @@ describe("dashboard reporting", () => { state: "completed", cumulativeDurationMs: 1_200, cumulativeUsage: { inputTokens: 100, outputTokens: 20 }, - modelId: "openai/gpt-5.5", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "high", piMessages: [ { @@ -393,13 +413,17 @@ describe("dashboard reporting", () => { }, ] as PiMessage[], }); - await recordAgentTurnSessionSummary({ + const { requestConversationWork } = + await import("@/chat/task-execution/store"); + await requestConversationWork({ conversationId: "slack:C1:222", - sessionId: "turn-running", - sliceId: 1, - state: "running", + destination: { + platform: "slack", + teamId: "T1", + channelId: "C1", + }, + nowMs: Date.now(), }); - const report = await readConversationDetailFromSql("slack:C1:222"); expect(report).toMatchObject({ cumulativeDurationMs: 1_200, @@ -456,6 +480,56 @@ describe("dashboard reporting", () => { ]); }); + it("does not report a prior turn model for a newer sparse run", async () => { + const { recordAgentTurnSessionSummary, upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + const { readConversationDetailFromSql } = + await import("@/api/conversations/detail.query"); + const conversationId = "slack:C1:sparse-current-run"; + await confirmPublicSlackConversation(conversationId); + await upsertAgentTurnSessionRecord({ + conversationId, + sessionId: "turn-old", + sliceId: 1, + state: "completed", + modelId: "openai/gpt-5.5", + piMessages: [], + }); + await recordAgentTurnSessionSummary({ + conversationId, + sessionId: "turn-new", + sliceId: 1, + state: "running", + }); + + await expect( + readConversationDetailFromSql(conversationId), + ).resolves.not.toHaveProperty("modelId"); + }); + + it("falls back to retained session metadata before turn rows exist", async () => { + const { upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + const { readConversationDetailFromSql } = + await import("@/api/conversations/detail.query"); + const conversationId = "slack:C1:legacy-turn-model"; + await confirmPublicSlackConversation(conversationId); + await upsertAgentTurnSessionRecord({ + conversationId, + sessionId: "turn-legacy", + sliceId: 1, + state: "completed", + modelId: "openai/gpt-5.5", + piMessages: [], + }); + + await expect( + readConversationDetailFromSql(conversationId), + ).resolves.toMatchObject({ + modelId: "openai/gpt-5.5", + }); + }); + it("reports private execution activity as safe metadata", async () => { const { upsertAgentTurnSessionRecord } = await import("@/chat/state/turn-session"); diff --git a/packages/junior/tests/integration/runtime/agent-run-model-handoff.test.ts b/packages/junior/tests/integration/runtime/agent-run-model-handoff.test.ts index ef8d46430..e1457fc86 100644 --- a/packages/junior/tests/integration/runtime/agent-run-model-handoff.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-model-handoff.test.ts @@ -9,6 +9,7 @@ const observations = vi.hoisted(() => ({ }>, afterHandoffToolNames: [] as string[], initialModelId: "", + initialSystemPrompt: "", initialToolNames: [] as string[], mixedBatch: false, providerCalls: 0, @@ -59,6 +60,7 @@ vi.mock("@/chat/pi/traced-stream", () => ({ const call = observations.providerCalls; if (call === 1) { observations.initialModelId = model.id; + observations.initialSystemPrompt = context.systemPrompt ?? ""; observations.initialToolNames = (context.tools ?? []).map( (tool: { name: string }) => tool.name, ); @@ -160,6 +162,7 @@ describe("executeAgentRun model handoff", () => { observations.afterHandoffMessages = []; observations.afterHandoffToolNames = []; observations.initialModelId = ""; + observations.initialSystemPrompt = ""; observations.initialToolNames = []; observations.mixedBatch = false; observations.providerCalls = 0; @@ -204,11 +207,18 @@ describe("executeAgentRun model handoff", () => { observations.textDeltas.push(text); }, }, + policy: { + instructions: ["Only report verified implementation evidence."], + toolPolicy: { mode: "allowlist", toolNames: ["handoff"] }, + }, }); expect(outcome.status).toBe("completed"); if (outcome.status !== "completed") return; expect(outcome.result.text).toBe("Handoff model completed it."); + expect(outcome.result.diagnostics.startingModelId).toBe( + observations.initialModelId, + ); expect(outcome.result.diagnostics.modelId).toBe("openai/gpt-5.6-sol"); expect( (outcome.result.diagnostics.usage?.inputTokens ?? 0) + @@ -218,6 +228,10 @@ describe("executeAgentRun model handoff", () => { expect(observations.initialModelId).not.toBe( observations.afterHandoffModelId, ); + expect(observations.initialSystemPrompt).toContain( + "Only report verified implementation evidence.", + ); + expect(observations.initialToolNames).toEqual(["handoff"]); expect(observations.afterHandoffModelId).toBe("openai/gpt-5.6-sol"); expect(observations.reasoningLevels.slice(0, 2)).toEqual(["high", "high"]); expect(observations.afterHandoffToolNames).not.toContain("handoff"); @@ -281,6 +295,7 @@ describe("executeAgentRun model handoff", () => { turnId: "turn-model-handoff-follow-up", }, }, + policy: { modelProfile: "coding" }, }); expect(followUp.status).toBe("completed"); if (followUp.status !== "completed") return; @@ -305,7 +320,7 @@ describe("executeAgentRun model handoff", () => { turnId: "turn-model-handoff-explicit-reasoning", }, }, - policy: { reasoningLevel: "xhigh" }, + policy: { reasoningPolicy: { mode: "fixed", level: "xhigh" } }, }); expect(outcome.status).toBe("completed"); @@ -500,5 +515,24 @@ describe("executeAgentRun model handoff", () => { expect(JSON.stringify(record?.piMessages)).not.toContain( "Implement the risky refactor.", ); + + const resumed = await executeAgentRun({ + input: { messageText: "" }, + routing: { + destination: { platform: "local", conversationId }, + source: createLocalSource(conversationId), + correlation: { + conversationId, + runId: "run-model-handoff-yield-resume", + turnId: sessionId, + }, + }, + }); + expect(resumed.status).toBe("completed"); + if (resumed.status !== "completed") return; + expect(resumed.result.diagnostics.startingModelId).toBe( + observations.initialModelId, + ); + expect(resumed.result.diagnostics.modelId).toBe("openai/gpt-5.6-sol"); }); }); diff --git a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts index c405d8cea..c5ce6db15 100644 --- a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts @@ -582,7 +582,7 @@ describe("executeAgentRun provider retry", () => { // Simulate the destination boundary committing completion after // acceptance; generation itself no longer persists the final reply. await persistCompletedSessionRecord({ - modelId: "test-model", + modelId: reply.diagnostics.modelId, conversationId: "slack:C123:1712345.0001", sessionId: "turn-steering", allMessages: reply.piMessages ?? [], diff --git a/packages/junior/tests/integration/runtime/conversation-runtime.test.ts b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts new file mode 100644 index 000000000..10f5632a9 --- /dev/null +++ b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLocalSource } from "@sentry/junior-plugin-api"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import type { ConversationExecutionProfile } from "@/chat/conversations/execution-profile"; +import { createConversationRuntime } from "@/chat/runtime/conversation-runtime"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; +import type { AgentRunner } from "@/chat/runtime/agent-runner"; +import { createLocalJuniorSqlFixture } from "../../fixtures/sql"; + +const CONVERSATION_ID = "local:test:conversation-runtime"; + +const PROFILE: ConversationExecutionProfile = { + modelProfile: "coding", + reasoning: { mode: "fixed", level: "high" }, + instructions: ["Focus on implementation evidence."], + toolPolicy: { mode: "allowlist", toolNames: ["bash", "read"] }, +}; + +const CHANGED_DEFAULT: ConversationExecutionProfile = { + modelProfile: "standard", + reasoning: { mode: "adaptive" }, + instructions: [], + toolPolicy: { mode: "host" }, +}; + +describe("conversation runtime", () => { + it("materializes one durable profile and reuses it across hosts", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateSchema(fixture.sql); + const profileStore = createSqlStore(fixture.sql); + await profileStore.recordActivity({ + conversationId: CONVERSATION_ID, + source: "local", + nowMs: 1_000, + }); + const run = vi.fn(async () => + completedAgentRun({ + text: "done", + diagnostics: { + assistantMessageCount: 1, + modelId: "fake-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }), + ); + const request = { + input: { messageText: "Implement it" }, + policy: { + instructions: ["Respect the host boundary."], + toolPolicy: { + mode: "allowlist" as const, + toolNames: ["read", "write"], + }, + }, + routing: { + destination: { + platform: "local" as const, + conversationId: CONVERSATION_ID, + }, + source: createLocalSource(CONVERSATION_ID), + correlation: { conversationId: CONVERSATION_ID }, + }, + }; + + await createConversationRuntime({ + agentRunner: { run }, + defaultProfile: PROFILE, + profileStore, + }).run(request); + await createConversationRuntime({ + agentRunner: { run }, + defaultProfile: CHANGED_DEFAULT, + profileStore, + }).run(request); + + const [storedProfile] = await fixture.sql.query<{ + execution_allowed_tool_names: string[] | null; + execution_instructions: string[]; + execution_model_profile: string | null; + execution_reasoning_level: string | null; + }>( + ` +SELECT + execution_allowed_tool_names, + execution_instructions, + execution_model_profile, + execution_reasoning_level +FROM junior_conversations +WHERE conversation_id = $1 +`, + [CONVERSATION_ID], + ); + + expect(run).toHaveBeenCalledTimes(2); + expect(storedProfile).toEqual({ + execution_allowed_tool_names: ["bash", "read"], + execution_instructions: ["Focus on implementation evidence."], + execution_model_profile: "coding", + execution_reasoning_level: "high", + }); + for (const [runtimeRequest] of run.mock.calls) { + expect(runtimeRequest.policy).toMatchObject({ + modelProfile: "coding", + reasoningPolicy: { mode: "fixed", level: "high" }, + instructions: [ + "Respect the host boundary.", + "Focus on implementation evidence.", + ], + toolPolicy: { + mode: "allowlist", + toolNames: ["read"], + }, + }); + } + await expect( + profileStore.getOrCreateExecutionProfile({ + conversationId: CONVERSATION_ID, + profile: CHANGED_DEFAULT, + }), + ).resolves.toEqual(PROFILE); + } finally { + await fixture.close(); + } + }); + + it("stores null host tools separately from an empty allowlist", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateSchema(fixture.sql); + const profileStore = createSqlStore(fixture.sql); + const conversationId = "local:test:adaptive-profile"; + + await expect( + profileStore.getOrCreateExecutionProfile({ + conversationId, + profile: CHANGED_DEFAULT, + }), + ).resolves.toEqual(CHANGED_DEFAULT); + + const [storedProfile] = await fixture.sql.query<{ + execution_allowed_tool_names: string[] | null; + execution_instructions: string[]; + execution_model_profile: string | null; + execution_reasoning_level: string | null; + }>( + ` +SELECT + execution_allowed_tool_names, + execution_instructions, + execution_model_profile, + execution_reasoning_level +FROM junior_conversations +WHERE conversation_id = $1 +`, + [conversationId], + ); + + expect(storedProfile).toEqual({ + execution_allowed_tool_names: null, + execution_instructions: [], + execution_model_profile: "standard", + execution_reasoning_level: null, + }); + + const noToolsConversationId = "local:test:no-tools-profile"; + const noToolsProfile: ConversationExecutionProfile = { + ...CHANGED_DEFAULT, + toolPolicy: { mode: "allowlist", toolNames: [] }, + }; + await expect( + profileStore.getOrCreateExecutionProfile({ + conversationId: noToolsConversationId, + profile: noToolsProfile, + }), + ).resolves.toEqual(noToolsProfile); + + const [storedNoToolsProfile] = await fixture.sql.query<{ + execution_allowed_tool_names: string[] | null; + }>( + ` +SELECT execution_allowed_tool_names +FROM junior_conversations +WHERE conversation_id = $1 +`, + [noToolsConversationId], + ); + expect(storedNoToolsProfile?.execution_allowed_tool_names).toEqual([]); + + await expect( + profileStore.getOrCreateExecutionProfile({ + conversationId: noToolsConversationId, + profile: CHANGED_DEFAULT, + }), + ).resolves.toEqual(noToolsProfile); + } finally { + await fixture.close(); + } + }); + + it("rejects malformed materialized profile columns", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateSchema(fixture.sql); + const profileStore = createSqlStore(fixture.sql); + const conversationId = "local:test:malformed-profile"; + await profileStore.getOrCreateExecutionProfile({ + conversationId, + profile: PROFILE, + }); + await fixture.sql.execute( + ` +UPDATE junior_conversations +SET execution_model_profile = '' +WHERE conversation_id = $1 +`, + [conversationId], + ); + + await expect( + profileStore.getOrCreateExecutionProfile({ + conversationId, + profile: CHANGED_DEFAULT, + }), + ).rejects.toThrow("Invalid string"); + } finally { + await fixture.close(); + } + }); + + it("rejects a profile key that disagrees with local routing", async () => { + const run = vi.fn(); + const runtime = createConversationRuntime({ + agentRunner: { run }, + defaultProfile: PROFILE, + profileStore: { + getOrCreateExecutionProfile: vi.fn(), + }, + }); + + await expect( + runtime.run({ + input: { messageText: "Implement it" }, + routing: { + destination: { + platform: "local", + conversationId: CONVERSATION_ID, + }, + source: createLocalSource(CONVERSATION_ID), + correlation: { conversationId: "local:test:other" }, + }, + }), + ).rejects.toThrow("Local conversation routing identity does not match"); + expect(run).not.toHaveBeenCalled(); + }); + + it("accepts an internal dispatch id with a Slack destination", async () => { + const run = vi.fn(async () => + completedAgentRun({ + text: "done", + diagnostics: { + assistantMessageCount: 1, + modelId: "fake-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }), + ); + const getOrCreateExecutionProfile = vi.fn(async () => PROFILE); + const runtime = createConversationRuntime({ + agentRunner: { run }, + defaultProfile: PROFILE, + profileStore: { getOrCreateExecutionProfile }, + }); + const conversationId = "agent-dispatch:scheduled-task-1"; + + await runtime.run({ + input: { messageText: "Run the scheduled task" }, + routing: { + destination: { + platform: "slack", + teamId: "T123", + channelId: "C123", + }, + source: { + platform: "slack", + type: "pub", + teamId: "T123", + channelId: "C123", + threadTs: "123.456", + }, + dispatch: {}, + correlation: { + conversationId, + threadId: conversationId, + teamId: "T123", + channelId: "C123", + }, + }, + }); + + expect(getOrCreateExecutionProfile).toHaveBeenCalledWith({ + conversationId, + profile: PROFILE, + }); + expect(run).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts b/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts index cf58ae31c..ed9b2816d 100644 --- a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts +++ b/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts @@ -5,7 +5,9 @@ const { agentMode, createSandboxCallCount, activeSandboxVersion, + botReasoningLevel, sessionRecordPiMessages, + sessionRecordReasoningLevel, selectedThinkingLevels, } = vi.hoisted(() => ({ agentMode: { @@ -17,9 +19,15 @@ const { activeSandboxVersion: { value: 1, }, + botReasoningLevel: { + value: undefined as string | undefined, + }, sessionRecordPiMessages: { value: [] as unknown[], }, + sessionRecordReasoningLevel: { + value: undefined as string | undefined, + }, selectedThinkingLevels: { value: [] as unknown[], }, @@ -112,6 +120,9 @@ vi.mock("@/chat/config", () => ({ fastModelId: "test-fast-model", modelId: "test-model", modelProfiles: { handoff: "test-handoff-model" }, + get reasoningLevel() { + return botReasoningLevel.value; + }, turnTimeoutMs: 1000, userName: "junior", }, @@ -212,6 +223,7 @@ vi.mock("@/chat/services/turn-session-record", () => ({ sessionRecordPiMessages.value.length > 0 ? { piMessages: [...sessionRecordPiMessages.value], + reasoningLevel: sessionRecordReasoningLevel.value, } : undefined, canUseTurnSession: false, @@ -396,7 +408,9 @@ describe("executeAgentRun lazy sandbox boot", () => { agentMode.value = "plain"; createSandboxCallCount.value = 0; activeSandboxVersion.value = 1; + botReasoningLevel.value = undefined; sessionRecordPiMessages.value = []; + sessionRecordReasoningLevel.value = undefined; selectedThinkingLevels.value = []; }); @@ -410,6 +424,56 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(selectedThinkingLevels.value).toEqual(["off"]); }); + it("reuses the durable reasoning level when adaptive reasoning resumes", async () => { + sessionRecordPiMessages.value = [ + { + role: "user", + content: [{ type: "text", text: "continue" }], + timestamp: 1, + }, + ]; + sessionRecordReasoningLevel.value = "high"; + + await generateLocalReply("hello", { + policy: { reasoningPolicy: { mode: "adaptive" } }, + }); + + expect(selectedThinkingLevels.value).toEqual(["high"]); + }); + + it("keeps fixed reasoning authoritative when a session resumes", async () => { + sessionRecordPiMessages.value = [ + { + role: "user", + content: [{ type: "text", text: "continue" }], + timestamp: 1, + }, + ]; + sessionRecordReasoningLevel.value = "high"; + + await generateLocalReply("hello", { + policy: { reasoningPolicy: { mode: "fixed", level: "low" } }, + }); + + expect(selectedThinkingLevels.value).toEqual(["low"]); + }); + + it("keeps fixed host reasoning authoritative when a session resumes", async () => { + botReasoningLevel.value = "low"; + sessionRecordPiMessages.value = [ + { + role: "user", + content: [{ type: "text", text: "continue" }], + timestamp: 1, + }, + ]; + sessionRecordReasoningLevel.value = "high"; + + await generateLocalReply("hello"); + + expect(selectedThinkingLevels.value).toEqual(["low"]); + }); + it("does not create a sandbox when loadSkill only reads host-side skill data", async () => { agentMode.value = "loadSkill";