From f092a442d47363159ece7d4fc47a02a762e46472 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 12:52:06 -0700 Subject: [PATCH 01/12] feat(runtime): Add durable conversation execution profiles Materialize immutable model, reasoning, instruction, and tool policies for conversation execution. Preserve handoff epochs and host authority across retries and continuation slices. Fixes #876 Co-Authored-By: OpenAI Codex --- .../migrations/0004_massive_the_stranger.sql | 1 + .../junior/migrations/meta/0004_snapshot.json | 999 ++++++++++++++++++ packages/junior/migrations/meta/_journal.json | 9 +- packages/junior/src/app.ts | 18 +- packages/junior/src/chat/agent/index.ts | 24 +- packages/junior/src/chat/agent/prompt.ts | 7 +- packages/junior/src/chat/agent/request.ts | 16 +- packages/junior/src/chat/agent/tools.ts | 34 +- packages/junior/src/chat/app/services.ts | 12 +- .../junior/src/chat/conversations/README.md | 13 + .../chat/conversations/execution-profile.ts | 74 ++ .../junior/src/chat/conversations/history.ts | 4 +- .../src/chat/conversations/projection.ts | 10 +- .../src/chat/conversations/sql/store.ts | 45 +- packages/junior/src/chat/db.ts | 9 +- packages/junior/src/chat/runtime/README.md | 13 + .../src/chat/runtime/conversation-runtime.ts | 108 ++ .../src/chat/services/turn-reasoning-level.ts | 2 +- .../junior/src/chat/task-execution/README.md | 2 + packages/junior/src/cli/chat.ts | 10 +- .../junior/src/db/schema/conversations.ts | 4 + .../conversation-transcripts-sql.test.ts | 11 +- .../runtime/agent-run-error-path.test.ts | 2 +- .../integration/conversation-sql.test.ts | 19 +- .../runtime/agent-run-model-handoff.test.ts | 14 +- .../runtime/conversation-runtime.test.ts | 136 +++ .../runtime/agent-run-lazy-sandbox.test.ts | 40 + 27 files changed, 1591 insertions(+), 45 deletions(-) create mode 100644 packages/junior/migrations/0004_massive_the_stranger.sql create mode 100644 packages/junior/migrations/meta/0004_snapshot.json create mode 100644 packages/junior/src/chat/conversations/execution-profile.ts create mode 100644 packages/junior/src/chat/runtime/conversation-runtime.ts create mode 100644 packages/junior/tests/integration/runtime/conversation-runtime.test.ts diff --git a/packages/junior/migrations/0004_massive_the_stranger.sql b/packages/junior/migrations/0004_massive_the_stranger.sql new file mode 100644 index 000000000..33153448f --- /dev/null +++ b/packages/junior/migrations/0004_massive_the_stranger.sql @@ -0,0 +1 @@ +ALTER TABLE "junior_conversations" ADD COLUMN "execution_profile_json" jsonb; \ 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..893035fe1 --- /dev/null +++ b/packages/junior/migrations/meta/0004_snapshot.json @@ -0,0 +1,999 @@ +{ + "id": "93b87ae4-cec1-4e93-8e42-d48ef4d0cd31", + "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 + }, + "execution_profile_json": { + "name": "execution_profile_json", + "type": "jsonb", + "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 + } + }, + "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..0197063b6 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1783915376595, "tag": "0003_peaceful_scalphunter", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783963297979, + "tag": "0004_massive_the_stranger", + "breakpoints": true } ] -} \ No newline at end of file +} 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..c0ffa2b95 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -114,6 +114,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,15 +212,19 @@ async function executeAgentRunInPrivacyContext( let canRecordMcpProviders = false; let turnUsage: AgentTurnUsage | undefined; let handoffPhaseUsage: AgentTurnUsage | undefined; - const configuredReasoningLevel = - policy.reasoningLevel ?? botConfig.reasoningLevel; + const configuredReasoningLevel = policy.reasoningPolicy + ? policy.reasoningPolicy.mode === "fixed" + ? policy.reasoningPolicy.level + : undefined + : botConfig.reasoningLevel; let reasoningSelection = configuredReasoningLevel ? configuredTurnReasoningLevel( configuredReasoningLevel, - policy.reasoningLevel ? "agent_config" : "default", + policy.reasoningPolicy ? "agent_config" : "default", ) : undefined; - let activeModelProfile: ModelProfile = STANDARD_MODEL_PROFILE; + let activeModelProfile: ModelProfile = + policy.modelProfile ?? STANDARD_MODEL_PROFILE; let activeModelId = modelIdForProfile(botConfig, activeModelProfile); const actor = actorFromRouting(routing); const surface = surfaceFromRouting(routing); @@ -277,6 +282,7 @@ async function executeAgentRunInPrivacyContext( const projection = await openConversationProjection({ conversationId: sessionConversationId, modelId: activeModelId, + modelProfile: activeModelProfile, }); activeModelProfile = projection.modelProfile; activeModelId = modelIdForProfile(botConfig, activeModelProfile); @@ -335,6 +341,15 @@ async function executeAgentRunInPrivacyContext( currentSliceId, existingSessionRecord, } = await restoreSessionRecord(routing); + if ( + existingSessionRecord?.reasoningLevel && + policy.reasoningPolicy?.mode !== "fixed" + ) { + 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 +639,7 @@ async function executeAgentRunInPrivacyContext( existingTurnStartMessageIndex: existingSessionRecord?.turnStartMessageIndex, invocation: skillInvocation, + instructions: policy.instructions, priorPiMessages, resumedFromSessionRecord, routing, 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..96906f8f4 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -58,6 +58,7 @@ 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"; interface ToolWiringArgs { abortAgent: () => void; @@ -111,6 +112,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,9 +348,12 @@ 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 toolGuidance = Object.entries(plannedToolExposure.directTools).map( ([name, definition]) => ({ name, @@ -398,9 +409,12 @@ export async function wireAgentTools( } } - const activeMcpCatalogs = toActiveMcpCatalogSummaries( - mcpToolManager.getActiveToolCatalog(), - ); + const activeMcpCatalogs = toolAllowed( + args.policy.toolPolicy, + "searchMcpTools", + ) + ? toActiveMcpCatalogSummaries(mcpToolManager.getActiveToolCatalog()) + : []; const onToolCall = async ( toolName: string, params: Record, @@ -422,7 +436,7 @@ export async function wireAgentTools( } }; const agentTools = createPiAgentTools( - tools, + allowedTools, args.skillSandbox, args.spanContext, args.observers.onStatus, @@ -432,7 +446,7 @@ export async function wireAgentTools( pluginHooks, args.conversationPrivacy, args.observers.onToolResult, - ); + ).filter((tool) => 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 +461,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..96d8a7c75 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -7,6 +7,8 @@ agent steps, compaction boundaries, search, retention, and legacy import. - Conversation rows identify the source, destination, participants, visibility, and lifecycle metadata. +- Conversation rows may carry a strict, versioned `execution_profile_json` + value containing provider-neutral behavior shared by every execution slice. - Visible messages are the destination-facing user and assistant history. - Agent steps are append-only execution history used to restore Pi state. - Context epochs identify replacement boundaries created by compaction or model @@ -16,6 +18,17 @@ agent steps, compaction boundaries, search, retention, and legacy import. 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. +- Model profile roles remain stable durable values while exact provider model + IDs continue to resolve through the current host model catalog. +- Initial context epochs use the stored baseline model role. Handoff, + compaction, and rollback epochs retain their own durable model binding. + ## 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..680dc08b6 --- /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(), +]); + +/** Durable, provider-neutral behavior selected for every run in a conversation. */ +export const conversationExecutionProfileSchema = z + .object({ + schemaVersion: z.literal(1), + 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 { + return { + schemaVersion: 1, + modelProfile: STANDARD_MODEL_PROFILE, + reasoning: config.reasoningLevel + ? { mode: "fixed", level: config.reasoningLevel } + : { mode: "adaptive" }, + instructions: [], + toolPolicy: { mode: "host" }, + }; +} diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index a60f986d7..9096bf1c1 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -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(), @@ -79,7 +79,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), }) diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 2b821c9ab..4436ef731 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -232,9 +232,9 @@ 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 }, + args: ScopedConversation & { modelId: string; modelProfile: ModelProfile }, ): Promise { await importLegacyIfNeeded(args); const stepStore = getAgentStepStore(); @@ -253,14 +253,14 @@ export async function openConversationProjection( // make that formerly implicit epoch explicit before model execution. await stepStore.startEpoch(args.conversationId, { reason: "initial", - modelProfile: "standard", + modelProfile: args.modelProfile, modelId: args.modelId, messages: [], }); return { messages: projection.messages, provenance: projection.provenance, - modelProfile: "standard", + modelProfile: args.modelProfile, modelId: args.modelId, }; } @@ -322,7 +322,7 @@ 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: { diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index a47080a66..59d2e506d 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -14,6 +14,12 @@ import type { ConversationStatus, ConversationStore, } from "../store"; +import { + conversationExecutionProfileSchema, + type ConversationExecutionProfile, + type ConversationExecutionProfileStore, +} from "../execution-profile"; +import { ensureConversationRow } from "./conversation-row"; import { juniorConversations, juniorDestinations, @@ -456,7 +462,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 +477,41 @@ 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({ executionProfile: juniorConversations.executionProfile }) + .from(juniorConversations) + .where(eq(juniorConversations.conversationId, args.conversationId)); + const existing = rows[0]?.executionProfile; + if (existing !== null && existing !== undefined) { + return conversationExecutionProfileSchema.parse(existing); + } + if (rows.length === 0) { + await ensureConversationRow( + this.executor, + args.conversationId, + args.nowMs ?? now(), + ); + } + await this.executor + .db() + .update(juniorConversations) + .set({ executionProfile: profile }) + .where(eq(juniorConversations.conversationId, args.conversationId)); + return profile; + }, + ); + } + async recordActivity(args: { activityAtMs?: number; channelName?: string; diff --git a/packages/junior/src/chat/db.ts b/packages/junior/src/chat/db.ts index ea1f73f9f..af9a15ded 100644 --- a/packages/junior/src/chat/db.ts +++ b/packages/junior/src/chat/db.ts @@ -1,6 +1,7 @@ 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"; @@ -15,7 +16,7 @@ let current: databaseUrl: string; db: JuniorSqlExecutor; driver: SqlDriver; - store: ConversationStore; + store: ConversationStore & ConversationExecutionProfileStore; stepStore: AgentStepStore; messageStore: ConversationMessageStore; searchStore: ConversationSearchStore; @@ -75,6 +76,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(); diff --git a/packages/junior/src/chat/runtime/README.md b/packages/junior/src/chat/runtime/README.md index 7b304a171..9fa4faecd 100644 --- a/packages/junior/src/chat/runtime/README.md +++ b/packages/junior/src/chat/runtime/README.md @@ -27,6 +27,19 @@ 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 profile owns the baseline model role, adaptive or fixed reasoning, + additional system instructions, and a restriction over host-provided tools. +- Profile tool policy can narrow host authority but cannot grant tools, + credentials, destinations, or other capabilities. +- Durable context epochs remain authoritative after model handoff; loading the + baseline profile must not roll the conversation back to its initial model. + ## Prompt Ownership - Core prompt text contains stable Junior behavior, not provider-specific setup. 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..5fe969a5f --- /dev/null +++ b/packages/junior/src/chat/runtime/conversation-runtime.ts @@ -0,0 +1,108 @@ +/** + * 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 (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/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/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..2b5051333 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -22,6 +22,10 @@ import type { } from "@/chat/local/runner"; import { executeAgentRun } from "@/chat/agent"; 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"; import type { JuniorPluginSet } from "@/plugins"; export const CHAT_USAGE = "usage: junior chat\n junior chat -p "; @@ -234,7 +238,11 @@ async function prepareLocalChatRun( await configureLocalChatPlugins(pluginSet); const { runLocalAgentTurn } = await import("@/chat/local/runner"); 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/conversations.ts b/packages/junior/src/db/schema/conversations.ts index 0f41d77a0..a99ec84f6 100644 --- a/packages/junior/src/db/schema/conversations.ts +++ b/packages/junior/src/db/schema/conversations.ts @@ -17,6 +17,7 @@ import type { ConversationSource, ConversationStatus, } from "@/chat/conversations/store"; +import type { ConversationExecutionProfile } from "@/chat/conversations/execution-profile"; export const juniorConversations = pgTable( "junior_conversations", @@ -64,6 +65,9 @@ export const juniorConversations = pgTable( executionDurationMs: integer("execution_duration_ms").notNull().default(0), executionUsage: jsonb("execution_usage_json").$type(), metricRunId: text("metric_run_id"), + executionProfile: jsonb( + "execution_profile_json", + ).$type(), }, (table) => [ index("junior_conversations_last_activity_idx").on( diff --git a/packages/junior/tests/component/conversation-transcripts-sql.test.ts b/packages/junior/tests/component/conversation-transcripts-sql.test.ts index 823f7544a..159b4fa7c 100644 --- a/packages/junior/tests/component/conversation-transcripts-sql.test.ts +++ b/packages/junior/tests/component/conversation-transcripts-sql.test.ts @@ -35,6 +35,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", @@ -138,6 +146,7 @@ it("opens an explicit initial epoch without dropping earlier host facts", async openConversationProjection({ conversationId, modelId: "openai/gpt-5.4", + modelProfile: "standard", }), ).resolves.toMatchObject({ messages: [], @@ -229,7 +238,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(5); } finally { await fixture.close(); } 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/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index 482ed0251..4866ede46 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,8 @@ 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_profile_json `); await Promise.all([ fixture.sql.query("SELECT 1"), @@ -194,7 +195,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 +263,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 +318,8 @@ 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_profile_json `); await migrateSchema(fixture.sql); @@ -345,7 +347,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 +379,9 @@ VALUES await fixture.sql.execute( "DROP INDEX junior_conversation_messages_search_idx", ); + await fixture.sql.execute( + "ALTER TABLE junior_conversations DROP COLUMN execution_profile_json", + ); await migrateSchema(fixture.sql); @@ -399,7 +404,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/runtime/agent-run-model-handoff.test.ts b/packages/junior/tests/integration/runtime/agent-run-model-handoff.test.ts index ef8d46430..c36434c70 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,6 +207,10 @@ 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"); @@ -218,6 +225,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 +292,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 +317,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"); 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..a8444e2ad --- /dev/null +++ b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts @@ -0,0 +1,136 @@ +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 = { + schemaVersion: 1, + modelProfile: "coding", + reasoning: { mode: "fixed", level: "high" }, + instructions: ["Focus on implementation evidence."], + toolPolicy: { mode: "allowlist", toolNames: ["bash", "read"] }, +}; + +const CHANGED_DEFAULT: ConversationExecutionProfile = { + schemaVersion: 1, + 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); + + expect(run).toHaveBeenCalledTimes(2); + 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("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(); + }); +}); 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..c4b0f240c 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 @@ -6,6 +6,7 @@ const { createSandboxCallCount, activeSandboxVersion, sessionRecordPiMessages, + sessionRecordReasoningLevel, selectedThinkingLevels, } = vi.hoisted(() => ({ agentMode: { @@ -20,6 +21,9 @@ const { sessionRecordPiMessages: { value: [] as unknown[], }, + sessionRecordReasoningLevel: { + value: undefined as string | undefined, + }, selectedThinkingLevels: { value: [] as unknown[], }, @@ -212,6 +216,7 @@ vi.mock("@/chat/services/turn-session-record", () => ({ sessionRecordPiMessages.value.length > 0 ? { piMessages: [...sessionRecordPiMessages.value], + reasoningLevel: sessionRecordReasoningLevel.value, } : undefined, canUseTurnSession: false, @@ -397,6 +402,7 @@ describe("executeAgentRun lazy sandbox boot", () => { createSandboxCallCount.value = 0; activeSandboxVersion.value = 1; sessionRecordPiMessages.value = []; + sessionRecordReasoningLevel.value = undefined; selectedThinkingLevels.value = []; }); @@ -410,6 +416,40 @@ 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("does not create a sandbox when loadSkill only reads host-side skill data", async () => { agentMode.value = "loadSkill"; From 90749e720627a8f49a0a112d4b8ac8aa7c56efe2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 13:09:21 -0700 Subject: [PATCH 02/12] fix(runtime): Preserve local config and resume reasoning Load local CLI runtime dependencies only after selecting the memory state adapter. Keep both explicit and host-configured fixed reasoning authoritative when restoring a resumable session. Refs #876 Co-Authored-By: OpenAI Codex --- packages/junior/src/chat/agent/index.ts | 3 ++- packages/junior/src/cli/chat.ts | 24 +++++++++++++------ .../tests/component/cli/chat-cli.test.ts | 4 ++++ .../runtime/agent-run-lazy-sandbox.test.ts | 24 +++++++++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index c0ffa2b95..900325b1f 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -343,7 +343,8 @@ async function executeAgentRunInPrivacyContext( } = await restoreSessionRecord(routing); if ( existingSessionRecord?.reasoningLevel && - policy.reasoningPolicy?.mode !== "fixed" + policy.reasoningPolicy?.mode !== "fixed" && + (policy.reasoningPolicy || !botConfig.reasoningLevel) ) { reasoningSelection = configuredTurnReasoningLevel( parseTurnReasoningLevel(existingSessionRecord.reasoningLevel), diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 2b5051333..221df7b8b 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -20,12 +20,6 @@ import type { LocalAgentTurnDeps, LocalToolResult, } from "@/chat/local/runner"; -import { executeAgentRun } from "@/chat/agent"; -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"; import type { JuniorPluginSet } from "@/plugins"; export const CHAT_USAGE = "usage: junior chat\n junior chat -p "; @@ -236,7 +230,23 @@ 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: createConversationRuntime({ agentRunner: createAgentRunner(executeAgentRun), 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/unit/runtime/agent-run-lazy-sandbox.test.ts b/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts index c4b0f240c..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,6 +5,7 @@ const { agentMode, createSandboxCallCount, activeSandboxVersion, + botReasoningLevel, sessionRecordPiMessages, sessionRecordReasoningLevel, selectedThinkingLevels, @@ -18,6 +19,9 @@ const { activeSandboxVersion: { value: 1, }, + botReasoningLevel: { + value: undefined as string | undefined, + }, sessionRecordPiMessages: { value: [] as unknown[], }, @@ -116,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", }, @@ -401,6 +408,7 @@ describe("executeAgentRun lazy sandbox boot", () => { agentMode.value = "plain"; createSandboxCallCount.value = 0; activeSandboxVersion.value = 1; + botReasoningLevel.value = undefined; sessionRecordPiMessages.value = []; sessionRecordReasoningLevel.value = undefined; selectedThinkingLevels.value = []; @@ -450,6 +458,22 @@ describe("executeAgentRun lazy sandbox boot", () => { 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"; From 2fbf44f048bc90fdb607221d38b02970dcc4c54e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 13:17:38 -0700 Subject: [PATCH 03/12] ref(runtime): Make execution policy flow explicit Replace nested conditional expressions with named state and straightforward branches so reasoning and tool-policy precedence are easier to follow. Refs #876 Co-Authored-By: OpenAI Codex --- packages/junior/src/chat/agent/index.ts | 37 +++++++++++-------- packages/junior/src/chat/agent/tools.ts | 12 +++--- .../chat/conversations/execution-profile.ts | 8 ++-- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 900325b1f..1bafd5e99 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -72,6 +72,7 @@ import { configuredTurnReasoningLevel, selectTurnReasoningLevel, toPiReasoningLevel, + type TurnReasoningSelection, } from "@/chat/services/turn-reasoning-level"; import { addAgentTurnUsage, @@ -212,17 +213,22 @@ async function executeAgentRunInPrivacyContext( let canRecordMcpProviders = false; let turnUsage: AgentTurnUsage | undefined; let handoffPhaseUsage: AgentTurnUsage | undefined; - const configuredReasoningLevel = policy.reasoningPolicy - ? policy.reasoningPolicy.mode === "fixed" - ? policy.reasoningPolicy.level - : undefined - : botConfig.reasoningLevel; - let reasoningSelection = configuredReasoningLevel - ? configuredTurnReasoningLevel( - configuredReasoningLevel, - policy.reasoningPolicy ? "agent_config" : "default", - ) - : undefined; + 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); @@ -341,11 +347,10 @@ async function executeAgentRunInPrivacyContext( currentSliceId, existingSessionRecord, } = await restoreSessionRecord(routing); - if ( - existingSessionRecord?.reasoningLevel && - policy.reasoningPolicy?.mode !== "fixed" && - (policy.reasoningPolicy || !botConfig.reasoningLevel) - ) { + const fixedReasoningConfigured = + policy.reasoningPolicy?.mode === "fixed" || + (!policy.reasoningPolicy && Boolean(botConfig.reasoningLevel)); + if (existingSessionRecord?.reasoningLevel && !fixedReasoningConfigured) { reasoningSelection = configuredTurnReasoningLevel( parseTurnReasoningLevel(existingSessionRecord.reasoningLevel), "resume", diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 96906f8f4..7f815fe38 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -409,12 +409,12 @@ export async function wireAgentTools( } } - const activeMcpCatalogs = toolAllowed( - args.policy.toolPolicy, - "searchMcpTools", - ) - ? toActiveMcpCatalogSummaries(mcpToolManager.getActiveToolCatalog()) - : []; + let activeMcpCatalogs: ReturnType = []; + if (toolAllowed(args.policy.toolPolicy, "searchMcpTools")) { + activeMcpCatalogs = toActiveMcpCatalogSummaries( + mcpToolManager.getActiveToolCatalog(), + ); + } const onToolCall = async ( toolName: string, params: Record, diff --git a/packages/junior/src/chat/conversations/execution-profile.ts b/packages/junior/src/chat/conversations/execution-profile.ts index 680dc08b6..351ab363a 100644 --- a/packages/junior/src/chat/conversations/execution-profile.ts +++ b/packages/junior/src/chat/conversations/execution-profile.ts @@ -62,12 +62,14 @@ export interface ConversationExecutionProfileStore { export function defaultConversationExecutionProfile( config: BotConfig, ): ConversationExecutionProfile { + let reasoning: ConversationReasoningPolicy = { mode: "adaptive" }; + if (config.reasoningLevel) { + reasoning = { mode: "fixed", level: config.reasoningLevel }; + } return { schemaVersion: 1, modelProfile: STANDARD_MODEL_PROFILE, - reasoning: config.reasoningLevel - ? { mode: "fixed", level: config.reasoningLevel } - : { mode: "adaptive" }, + reasoning, instructions: [], toolPolicy: { mode: "host" }, }; From 0d3db15fb960d99da8f5e6cd504296e67dc336fa Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 13:24:22 -0700 Subject: [PATCH 04/12] fix(runtime): Preserve allowed deferred tool dispatch Expose catalog dispatchers automatically when the filtered tool set contains allowed deferred tools, without granting access to tools outside the profile. Refs #876 Co-Authored-By: OpenAI Codex --- packages/junior/src/chat/agent/tools.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 7f815fe38..bf3f6c5a6 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -59,6 +59,8 @@ 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; @@ -354,6 +356,9 @@ export async function wireAgentTools( ), ) 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]) => ({ name, @@ -446,7 +451,14 @@ export async function wireAgentTools( pluginHooks, args.conversationPrivacy, args.observers.onToolResult, - ).filter((tool) => toolAllowed(args.policy.toolPolicy, tool.name)); + ).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. From 032b8caf5f6c322dd3feec3eb8b5f214fb633130 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 14:07:33 -0700 Subject: [PATCH 05/12] ref(runtime): Store execution profile in typed columns Replace the versioned JSON execution profile with the minimum typed conversation columns. Keep host and adaptive modes encoded through nullability while preserving empty tool allowlists as a distinct restriction. Refs #876 Co-Authored-By: OpenAI Codex --- .../junior/migrations/0004_big_malice.sql | 4 + .../migrations/0004_massive_the_stranger.sql | 1 - .../junior/migrations/meta/0004_snapshot.json | 33 ++++- packages/junior/migrations/meta/_journal.json | 4 +- .../junior/src/chat/conversations/README.md | 8 +- .../chat/conversations/execution-profile.ts | 2 - .../src/chat/conversations/sql/store.ts | 82 ++++++++++- .../junior/src/db/schema/conversations.ts | 11 +- .../integration/conversation-sql.test.ts | 18 ++- .../runtime/conversation-runtime.test.ts | 132 +++++++++++++++++- 10 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 packages/junior/migrations/0004_big_malice.sql delete mode 100644 packages/junior/migrations/0004_massive_the_stranger.sql 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/0004_massive_the_stranger.sql b/packages/junior/migrations/0004_massive_the_stranger.sql deleted file mode 100644 index 33153448f..000000000 --- a/packages/junior/migrations/0004_massive_the_stranger.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "junior_conversations" ADD COLUMN "execution_profile_json" jsonb; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0004_snapshot.json b/packages/junior/migrations/meta/0004_snapshot.json index 893035fe1..82cd4ce52 100644 --- a/packages/junior/migrations/meta/0004_snapshot.json +++ b/packages/junior/migrations/meta/0004_snapshot.json @@ -1,5 +1,5 @@ { - "id": "93b87ae4-cec1-4e93-8e42-d48ef4d0cd31", + "id": "8af4c564-90d1-4e92-aa71-e7f110d28c3d", "prevId": "3668b705-7991-4e7e-8fb2-6e4ef8147aac", "version": "7", "dialect": "postgresql", @@ -313,12 +313,6 @@ "primaryKey": false, "notNull": false }, - "execution_profile_json": { - "name": "execution_profile_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -410,6 +404,31 @@ "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": { diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 0197063b6..7a898709a 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -33,8 +33,8 @@ { "idx": 4, "version": "7", - "when": 1783963297979, - "tag": "0004_massive_the_stranger", + "when": 1783976272513, + "tag": "0004_big_malice", "breakpoints": true } ] diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index 96d8a7c75..f600ca4d6 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -7,8 +7,8 @@ agent steps, compaction boundaries, search, retention, and legacy import. - Conversation rows identify the source, destination, participants, visibility, and lifecycle metadata. -- Conversation rows may carry a strict, versioned `execution_profile_json` - value containing provider-neutral behavior shared by every execution slice. +- 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. - Agent steps are append-only execution history used to restore Pi state. - Context epochs identify replacement boundaries created by compaction or model @@ -24,6 +24,10 @@ The schemas and migrations under `sql/` are authoritative. 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 while exact provider model IDs continue to resolve through the current host model catalog. - Initial context epochs use the stored baseline model role. Handoff, diff --git a/packages/junior/src/chat/conversations/execution-profile.ts b/packages/junior/src/chat/conversations/execution-profile.ts index 351ab363a..64a67e5e2 100644 --- a/packages/junior/src/chat/conversations/execution-profile.ts +++ b/packages/junior/src/chat/conversations/execution-profile.ts @@ -29,7 +29,6 @@ export const conversationReasoningPolicySchema = z.discriminatedUnion("mode", [ /** Durable, provider-neutral behavior selected for every run in a conversation. */ export const conversationExecutionProfileSchema = z .object({ - schemaVersion: z.literal(1), modelProfile: modelProfileSchema, reasoning: conversationReasoningPolicySchema, instructions: z.array(z.string().trim().min(1)), @@ -67,7 +66,6 @@ export function defaultConversationExecutionProfile( reasoning = { mode: "fixed", level: config.reasoningLevel }; } return { - schemaVersion: 1, modelProfile: STANDARD_MODEL_PROFILE, reasoning, instructions: [], diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index 59d2e506d..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 { @@ -53,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(); @@ -488,12 +555,17 @@ export class SqlStore async () => { const rows = await this.executor .db() - .select({ executionProfile: juniorConversations.executionProfile }) + .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]?.executionProfile; - if (existing !== null && existing !== undefined) { - return conversationExecutionProfileSchema.parse(existing); + const existing = rows[0]; + if (existing && existing.modelProfile !== null) { + return executionProfileFromRow(existing); } if (rows.length === 0) { await ensureConversationRow( @@ -505,7 +577,7 @@ export class SqlStore await this.executor .db() .update(juniorConversations) - .set({ executionProfile: profile }) + .set(executionProfileColumns(profile)) .where(eq(juniorConversations.conversationId, args.conversationId)); return profile; }, diff --git a/packages/junior/src/db/schema/conversations.ts b/packages/junior/src/db/schema/conversations.ts index a99ec84f6..f7226e567 100644 --- a/packages/junior/src/db/schema/conversations.ts +++ b/packages/junior/src/db/schema/conversations.ts @@ -17,7 +17,6 @@ import type { ConversationSource, ConversationStatus, } from "@/chat/conversations/store"; -import type { ConversationExecutionProfile } from "@/chat/conversations/execution-profile"; export const juniorConversations = pgTable( "junior_conversations", @@ -65,9 +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"), - executionProfile: jsonb( - "execution_profile_json", - ).$type(), + 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/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index 4866ede46..69e9b1f9d 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -184,7 +184,10 @@ ALTER TABLE junior_conversations DROP COLUMN execution_duration_ms, DROP COLUMN execution_usage_json, DROP COLUMN metric_run_id, - DROP COLUMN execution_profile_json + 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"), @@ -319,7 +322,10 @@ ALTER TABLE junior_conversations DROP COLUMN execution_duration_ms, DROP COLUMN execution_usage_json, DROP COLUMN metric_run_id, - DROP COLUMN execution_profile_json + DROP COLUMN execution_model_profile, + DROP COLUMN execution_reasoning_level, + DROP COLUMN execution_instructions, + DROP COLUMN execution_allowed_tool_names `); await migrateSchema(fixture.sql); @@ -380,7 +386,13 @@ VALUES "DROP INDEX junior_conversation_messages_search_idx", ); await fixture.sql.execute( - "ALTER TABLE junior_conversations DROP COLUMN execution_profile_json", + ` +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); diff --git a/packages/junior/tests/integration/runtime/conversation-runtime.test.ts b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts index a8444e2ad..eecacfeca 100644 --- a/packages/junior/tests/integration/runtime/conversation-runtime.test.ts +++ b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts @@ -11,7 +11,6 @@ import { createLocalJuniorSqlFixture } from "../../fixtures/sql"; const CONVERSATION_ID = "local:test:conversation-runtime"; const PROFILE: ConversationExecutionProfile = { - schemaVersion: 1, modelProfile: "coding", reasoning: { mode: "fixed", level: "high" }, instructions: ["Focus on implementation evidence."], @@ -19,7 +18,6 @@ const PROFILE: ConversationExecutionProfile = { }; const CHANGED_DEFAULT: ConversationExecutionProfile = { - schemaVersion: 1, modelProfile: "standard", reasoning: { mode: "adaptive" }, instructions: [], @@ -82,7 +80,31 @@ describe("conversation runtime", () => { 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", @@ -108,6 +130,112 @@ describe("conversation runtime", () => { } }); + 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({ From 17641a7ce4db7b7ef4647074421a78fcbf56cdc0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 14:47:23 -0700 Subject: [PATCH 06/12] docs(runtime): Clarify execution profile terminology Define the conversation execution profile as immutable conversation-owned configuration used by each execution slice. Remove baseline and policy wording that obscures the established term. Refs #876 Co-Authored-By: OpenAI Codex --- packages/junior/src/chat/conversations/README.md | 2 +- packages/junior/src/chat/conversations/execution-profile.ts | 2 +- packages/junior/src/chat/runtime/README.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index f600ca4d6..c09449127 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -30,7 +30,7 @@ The schemas and migrations under `sql/` are authoritative. `execution_instructions` stores the durable instruction list. - Model profile roles remain stable durable values while exact provider model IDs continue to resolve through the current host model catalog. -- Initial context epochs use the stored baseline model role. Handoff, +- Initial context epochs use the stored model profile. Handoff, compaction, and rollback epochs retain their own durable model binding. ## Write Rules diff --git a/packages/junior/src/chat/conversations/execution-profile.ts b/packages/junior/src/chat/conversations/execution-profile.ts index 64a67e5e2..4a2ab9716 100644 --- a/packages/junior/src/chat/conversations/execution-profile.ts +++ b/packages/junior/src/chat/conversations/execution-profile.ts @@ -26,7 +26,7 @@ export const conversationReasoningPolicySchema = z.discriminatedUnion("mode", [ .strict(), ]); -/** Durable, provider-neutral behavior selected for every run in a conversation. */ +/** Immutable conversation configuration used for each execution slice. */ export const conversationExecutionProfileSchema = z .object({ modelProfile: modelProfileSchema, diff --git a/packages/junior/src/chat/runtime/README.md b/packages/junior/src/chat/runtime/README.md index 9fa4faecd..6d6335baf 100644 --- a/packages/junior/src/chat/runtime/README.md +++ b/packages/junior/src/chat/runtime/README.md @@ -33,12 +33,12 @@ this directory owns product orchestration around it. 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 profile owns the baseline model role, adaptive or fixed reasoning, - additional system instructions, and a restriction over host-provided tools. +- 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 context epochs remain authoritative after model handoff; loading the - baseline profile must not roll the conversation back to its initial model. + execution profile must not roll the conversation back to its initial model. ## Prompt Ownership From e78f8b3f5151e442f3bd502707eca6930d580e92 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 15:51:08 -0700 Subject: [PATCH 07/12] fix(reporting): Preserve turn starting model Keep the model used when a turn begins separate from the model active at the latest persisted boundary. Conversation detail headers now show the starting model while handoff events retain the transition to the new model. Refs #876 Co-Authored-By: OpenAI Codex --- .../src/api/conversations/detail.query.ts | 3 +- .../junior/src/chat/agent-dispatch/runner.ts | 1 + packages/junior/src/chat/agent/index.ts | 10 +++++ packages/junior/src/chat/agent/resume.ts | 2 + packages/junior/src/chat/local/runner.ts | 1 + packages/junior/src/chat/runtime/README.md | 2 + .../junior/src/chat/runtime/reply-executor.ts | 1 + .../junior/src/chat/runtime/slack-resume.ts | 1 + .../junior/src/chat/services/turn-result.ts | 5 +++ .../src/chat/services/turn-session-record.ts | 23 ++++++++++ .../junior/src/chat/state/turn-session.ts | 43 +++++++++++++++++++ .../services/turn-session-record.test.ts | 9 ++-- .../integration/dashboard-reporting.test.ts | 3 +- .../runtime/agent-run-model-handoff.test.ts | 23 ++++++++++ 14 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/junior/src/api/conversations/detail.query.ts b/packages/junior/src/api/conversations/detail.query.ts index cb2957d9b..6281b9c75 100644 --- a/packages/junior/src/api/conversations/detail.query.ts +++ b/packages/junior/src/api/conversations/detail.query.ts @@ -36,9 +36,10 @@ export async function readConversationDetailFromSql( }), readLatestRun(conversationId), ]); + const modelId = latestRun?.startingModelId ?? latestRun?.modelId; return { ...report, - ...(latestRun?.modelId ? { modelId: latestRun.modelId } : {}), + ...(modelId ? { modelId } : {}), ...(latestRun?.reasoningLevel ? { reasoningLevel: latestRun.reasoningLevel } : {}), diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts index cbef26fab..008119488 100644 --- a/packages/junior/src/chat/agent-dispatch/runner.ts +++ b/packages/junior/src/chat/agent-dispatch/runner.ts @@ -482,6 +482,7 @@ export async function runAgentDispatchSlice( sessionId: turnId, sliceId: 1, messages: reply.piMessages, + startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, durationMs: reply.diagnostics.durationMs, usage: reply.diagnostics.usage, diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 1bafd5e99..7e02ecbf2 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -232,6 +232,7 @@ async function executeAgentRunInPrivacyContext( 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; @@ -293,6 +294,7 @@ async function executeAgentRunInPrivacyContext( activeModelProfile = projection.modelProfile; activeModelId = modelIdForProfile(botConfig, activeModelProfile); } + startingModelId = activeModelId; const shouldTrace = shouldEmitDevAgentTrace(); const spanContext: LogContext = { conversationId: sessionConversationId, @@ -347,6 +349,11 @@ async function executeAgentRunInPrivacyContext( currentSliceId, existingSessionRecord, } = await restoreSessionRecord(routing); + if (existingSessionRecord?.startingModelId) { + startingModelId = existingSessionRecord.startingModelId; + } else if (existingSessionRecord?.modelId) { + startingModelId = existingSessionRecord.modelId; + } const fixedReasoningConfigured = policy.reasoningPolicy?.mode === "fixed" || (!policy.reasoningPolicy && Boolean(botConfig.reasoningLevel)); @@ -387,6 +394,7 @@ async function executeAgentRunInPrivacyContext( sessionConversationId, sessionId, sessionRecordState, + startingModelId, startedAtMs: replyStartedAtMs, surface, }); @@ -1130,6 +1138,7 @@ async function executeAgentRunInPrivacyContext( reasoningSelection, correlation: routing.correlation, assistantUserName: botConfig.userName, + startingModelId, modelId: activeModelId, }), }; @@ -1186,6 +1195,7 @@ async function executeAgentRunInPrivacyContext( ...getSandboxMetadata(), diagnostics: { outcome: "provider_error", + startingModelId, modelId: activeModelId, assistantMessageCount: 0, ...(reasoningSelection diff --git a/packages/junior/src/chat/agent/resume.ts b/packages/junior/src/chat/agent/resume.ts index bd5468600..2ffcb836e 100644 --- a/packages/junior/src/chat/agent/resume.ts +++ b/packages/junior/src/chat/agent/resume.ts @@ -51,6 +51,7 @@ interface ResumeStateArgs { sessionConversationId?: string; sessionId?: string; sessionRecordState: LoadedSessionRecordState; + startingModelId: string; startedAtMs: number; surface?: AgentTurnSurface; } @@ -94,6 +95,7 @@ export function createResumeState(args: ResumeStateArgs) { sessionId: args.sessionId!, loadedSkillNames: args.getLoadedSkillNames(), logContext: args.logContext, + startingModelId: args.startingModelId, modelId: args.getModelId(), ...(args.getReasoningLevel() ? { reasoningLevel: args.getReasoningLevel() } diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index ad1b4bf58..bc0e6ad44 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -348,6 +348,7 @@ export async function runLocalAgentTurn( sessionId: turnId, sliceId: 1, messages: reply.piMessages, + startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, durationMs: reply.diagnostics.durationMs, usage: reply.diagnostics.usage, diff --git a/packages/junior/src/chat/runtime/README.md b/packages/junior/src/chat/runtime/README.md index 6d6335baf..2c86f8a71 100644 --- a/packages/junior/src/chat/runtime/README.md +++ b/packages/junior/src/chat/runtime/README.md @@ -37,6 +37,8 @@ this directory owns product orchestration around it. reasoning, additional instructions, and tool restrictions. - Profile tool policy can narrow host authority but cannot grant tools, credentials, destinations, or other capabilities. +- Turn-session metadata keeps the starting model separate from the latest + active model so reporting does not rewrite the start after a handoff. - Durable context epochs remain authoritative after model handoff; loading the execution profile must not roll the conversation back to its initial model. diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 044bf604a..1c9e410fd 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -1484,6 +1484,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { sessionId: turnId, sliceId: 1, messages: reply.piMessages, + startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, logContext: { threadId, diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index 507e2ae52..77b280c71 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -497,6 +497,7 @@ export async function resumeSlackTurn( conversationId: replyContext.routing.correlation.conversationId, sessionId: replyContext.routing.correlation.turnId, allMessages: reply.piMessages, + startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, currentDurationMs: reply.diagnostics.durationMs, currentUsage: reply.diagnostics.usage, 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/services/turn-session-record.ts b/packages/junior/src/chat/services/turn-session-record.ts index 148727b39..4667b3c3e 100644 --- a/packages/junior/src/chat/services/turn-session-record.ts +++ b/packages/junior/src/chat/services/turn-session-record.ts @@ -139,6 +139,7 @@ export async function persistRunningSessionRecord(args: { trailingMessageProvenance?: PiMessageProvenance[]; loadedSkillNames?: string[]; logContext: SessionRecordLogContext; + startingModelId?: string; modelId: string; actor?: Actor; reasoningLevel?: string; @@ -180,6 +181,9 @@ export async function persistRunningSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + ...(args.startingModelId + ? { startingModelId: args.startingModelId } + : {}), modelId: args.modelId, ...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel } : {}), ...((args.actor ?? latestSessionRecord?.actor) @@ -236,6 +240,7 @@ export async function persistCompletedSessionRecord(args: { allMessages: PiMessage[]; loadedSkillNames?: string[]; logContext: SessionRecordLogContext; + startingModelId?: string; modelId: string; actor?: Actor; reasoningLevel?: string; @@ -295,6 +300,7 @@ export async function persistCompletedSessionRecord(args: { args.loadedSkillNames ?? latestSessionRecord?.loadedSkillNames, } : {}), + ...(args.startingModelId ? { startingModelId: args.startingModelId } : {}), modelId, ...(reasoningLevel ? { reasoningLevel } : {}), ...((args.actor ?? latestSessionRecord?.actor) @@ -327,6 +333,7 @@ export async function completeDeliveredTurn(args: { loadedSkillNames?: string[]; logContext: SessionRecordLogContext; messages: PiMessage[]; + startingModelId?: string; modelId: string; actor?: Actor; reasoningLevel?: string; @@ -350,6 +357,7 @@ export async function completeDeliveredTurn(args: { allMessages: args.messages, loadedSkillNames: args.loadedSkillNames, logContext: args.logContext, + startingModelId: args.startingModelId, modelId: args.modelId, actor: args.actor, reasoningLevel: args.reasoningLevel, @@ -373,6 +381,7 @@ export async function persistAuthPauseSessionRecord(args: { source?: Source; messages: PiMessage[]; loadedSkillNames?: string[]; + startingModelId?: string; modelId: string; errorMessage: string; logContext: SessionRecordLogContext; @@ -422,6 +431,9 @@ export async function persistAuthPauseSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + ...(args.startingModelId + ? { startingModelId: args.startingModelId } + : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { @@ -469,6 +481,7 @@ export async function persistTimeoutSessionRecord(args: { source?: Source; messages: PiMessage[]; loadedSkillNames?: string[]; + startingModelId?: string; modelId: string; errorMessage: string; logContext: SessionRecordLogContext; @@ -526,6 +539,9 @@ export async function persistTimeoutSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + ...(args.startingModelId + ? { startingModelId: args.startingModelId } + : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { @@ -567,6 +583,9 @@ export async function persistTimeoutSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + ...(args.startingModelId + ? { startingModelId: args.startingModelId } + : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { @@ -613,6 +632,7 @@ export async function persistYieldSessionRecord(args: { source?: Source; messages: PiMessage[]; loadedSkillNames?: string[]; + startingModelId?: string; modelId: string; errorMessage: string; logContext: SessionRecordLogContext; @@ -661,6 +681,9 @@ export async function persistYieldSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + ...(args.startingModelId + ? { startingModelId: args.startingModelId } + : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { diff --git a/packages/junior/src/chat/state/turn-session.ts b/packages/junior/src/chat/state/turn-session.ts index c7fcf2037..1afef6674 100644 --- a/packages/junior/src/chat/state/turn-session.ts +++ b/packages/junior/src/chat/state/turn-session.ts @@ -65,6 +65,9 @@ export interface AgentTurnSessionRecord { errorMessage?: string; lastProgressAtMs: number; loadedSkillNames?: string[]; + /** Exact model used when this turn began. */ + startingModelId?: string; + /** Model active at the latest persisted boundary. */ modelId?: string; reasoningLevel?: string; piMessages: PiMessage[]; @@ -151,6 +154,7 @@ const agentTurnSessionSummarySchema = z source: sourceSchema.optional(), lastProgressAtMs: nonNegativeNumberSchema, loadedSkillNames: z.array(z.string()).optional(), + startingModelId: z.string().min(1).optional(), modelId: z.string().min(1).optional(), reasoningLevel: z.string().min(1).optional(), actor: actorSchema.optional(), @@ -175,6 +179,23 @@ const storedAgentTurnSessionRecordSchema = agentTurnSessionSummarySchema }) .strict() satisfies z.ZodType; +function resolveStartingModelId(args: { + existing?: Pick; + modelId?: string; + startingModelId?: string; +}): string | undefined { + if (args.existing?.startingModelId) { + return args.existing.startingModelId; + } + if (args.existing?.modelId) { + return args.existing.modelId; + } + if (args.startingModelId) { + return args.startingModelId; + } + return args.modelId; +} + function agentTurnSessionKey( conversationId: string, sessionId: string, @@ -306,6 +327,9 @@ function materializeAgentTurnSessionRecord( ...(stored.loadedSkillNames ? { loadedSkillNames: stored.loadedSkillNames } : {}), + ...(stored.startingModelId + ? { startingModelId: stored.startingModelId } + : {}), ...(stored.modelId ? { modelId: stored.modelId } : {}), ...(stored.reasoningLevel ? { reasoningLevel: stored.reasoningLevel } : {}), ...(stored.actor ? { actor: stored.actor } : {}), @@ -378,6 +402,7 @@ function buildStoredRecord(args: { committedSeq: number; lastProgressAtMs?: number; loadedSkillNames?: string[]; + startingModelId?: string; modelId?: string; previousVersion?: number; reasoningLevel?: string; @@ -418,6 +443,7 @@ function buildStoredRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + ...(args.startingModelId ? { startingModelId: args.startingModelId } : {}), ...(args.modelId ? { modelId: args.modelId } : {}), ...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel } : {}), ...(args.resumeReason ? { resumeReason: args.resumeReason } : {}), @@ -520,6 +546,9 @@ async function updateAgentTurnSessionState(args: { ...(args.existing.loadedSkillNames ? { loadedSkillNames: args.existing.loadedSkillNames } : {}), + ...(args.existing.startingModelId + ? { startingModelId: args.existing.startingModelId } + : {}), ...(args.existing.modelId ? { modelId: args.existing.modelId } : {}), ...(args.existing.reasoningLevel ? { reasoningLevel: args.existing.reasoningLevel } @@ -553,6 +582,7 @@ export async function upsertAgentTurnSessionRecord(args: { source?: Source; lastProgressAtMs?: number; loadedSkillNames?: string[]; + startingModelId?: string; modelId: string; conversationStore?: ConversationStore; sessionId: string; @@ -577,6 +607,11 @@ export async function upsertAgentTurnSessionRecord(args: { args.sessionId, ); const ttlMs = Math.max(1, args.ttlMs ?? AGENT_TURN_SESSION_TTL_MS); + const startingModelId = resolveStartingModelId({ + existing: existingRecord, + modelId: args.modelId, + startingModelId: args.startingModelId, + }); // Attribute new user input to the turn's actor as an instruction; the step // store reuses committed provenance for the unchanged prefix and defaults the // rest to context. Platform-neutral so local identities are preserved too. @@ -645,6 +680,7 @@ export async function upsertAgentTurnSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), + startingModelId, modelId: args.modelId, ...((args.reasoningLevel ?? existingRecord?.reasoningLevel) ? { @@ -691,6 +727,7 @@ export async function recordAgentTurnSessionSummary(args: { source?: Source; lastProgressAtMs?: number; loadedSkillNames?: string[]; + startingModelId?: string; modelId?: string; conversationStore?: ConversationStore; actor?: Actor; @@ -710,6 +747,11 @@ export async function recordAgentTurnSessionSummary(args: { ); const nowMs = Date.now(); const ttlMs = Math.max(1, args.ttlMs ?? AGENT_TURN_SESSION_TTL_MS); + const startingModelId = resolveStartingModelId({ + existing, + modelId: args.modelId, + startingModelId: args.startingModelId, + }); const summary: AgentTurnSessionSummary = { version: existing?.version ?? 0, ...((args.channelName ?? existing?.channelName) @@ -741,6 +783,7 @@ export async function recordAgentTurnSessionSummary(args: { : existing?.loadedSkillNames ? { loadedSkillNames: existing.loadedSkillNames } : {}), + ...(startingModelId ? { startingModelId } : {}), ...((args.modelId ?? existing?.modelId) ? { modelId: args.modelId ?? existing?.modelId } : {}), 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..3dbe2c81c 100644 --- a/packages/junior/tests/component/services/turn-session-record.test.ts +++ b/packages/junior/tests/component/services/turn-session-record.test.ts @@ -1208,7 +1208,8 @@ describe("persistAuthPauseSessionRecord", () => { sessionId, sliceId: 2, state: "running", - modelId: "openai/gpt-5.6", + startingModelId: "openai/gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", piMessages: [userMessage("continue")], }); @@ -1216,7 +1217,8 @@ describe("persistAuthPauseSessionRecord", () => { await expect( getAgentTurnSessionRecord(conversationId, sessionId), ).resolves.toMatchObject({ - modelId: "openai/gpt-5.6", + startingModelId: "openai/gpt-5.6", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", }); expect( @@ -1224,7 +1226,8 @@ describe("persistAuthPauseSessionRecord", () => { (summary) => summary.sessionId === sessionId, ), ).toMatchObject({ - modelId: "openai/gpt-5.6", + startingModelId: "openai/gpt-5.6", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", }); }); diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 6178785b7..44573a52d 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -335,7 +335,8 @@ describe("dashboard reporting", () => { state: "completed", cumulativeDurationMs: 1_200, cumulativeUsage: { inputTokens: 100, outputTokens: 20 }, - modelId: "openai/gpt-5.5", + startingModelId: "openai/gpt-5.5", + modelId: "openai/gpt-5.6-sol", reasoningLevel: "high", piMessages: [ { 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 c36434c70..3dedb7fb6 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 @@ -216,6 +216,9 @@ describe("executeAgentRun model 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) + @@ -503,6 +506,7 @@ describe("executeAgentRun model handoff", () => { expect(outcome.status).toBe("suspended"); const record = await getAgentTurnSessionRecord(conversationId, sessionId); expect(record).toMatchObject({ + startingModelId: observations.initialModelId, modelId: "openai/gpt-5.6-sol", state: "awaiting_resume", }); @@ -512,5 +516,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"); }); }); From 6d24a8950964c712755e68297897570fffed35e4 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 16:00:21 -0700 Subject: [PATCH 08/12] fix(runtime): Accept internal dispatch conversation ids Treat dispatch routing as an internal conversation identity before applying Slack thread parsing. Keep correlation and destination checks intact while allowing agent-dispatch records to use their canonical ids. Refs #876 Co-Authored-By: OpenAI Codex --- .../src/chat/runtime/conversation-runtime.ts | 11 ++++ .../runtime/conversation-runtime.test.ts | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/junior/src/chat/runtime/conversation-runtime.ts b/packages/junior/src/chat/runtime/conversation-runtime.ts index 5fe969a5f..61ab21baa 100644 --- a/packages/junior/src/chat/runtime/conversation-runtime.ts +++ b/packages/junior/src/chat/runtime/conversation-runtime.ts @@ -46,6 +46,17 @@ function conversationIdForProfile(request: AgentRunRequest): string { 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" || diff --git a/packages/junior/tests/integration/runtime/conversation-runtime.test.ts b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts index eecacfeca..10f5632a9 100644 --- a/packages/junior/tests/integration/runtime/conversation-runtime.test.ts +++ b/packages/junior/tests/integration/runtime/conversation-runtime.test.ts @@ -261,4 +261,59 @@ WHERE conversation_id = $1 ).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(); + }); }); From fae326a92195f3c73fecbae534c8f0ef9abff35d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 17:14:25 -0700 Subject: [PATCH 09/12] fix(runtime): Bind turns to model epochs Record each durable turn as an identity plus its starting agent-step boundary. Derive the starting model from the containing context epoch instead of duplicating model state in messages, SQL, or resume records. Require every context epoch to own one exact model. Open an explicit model_change epoch when a fresh turn resolves its profile differently. Preserve the binding across resumes and compaction, and reject commits from a different model. Refs #876 Co-Authored-By: OpenAI Codex --- .../migrations/0005_solid_franklin_storm.sql | 9 + .../junior/migrations/meta/0005_snapshot.json | 1073 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../api/conversations/detail-projection.ts | 5 +- .../src/api/conversations/detail.query.ts | 20 +- .../junior/src/chat/agent-dispatch/runner.ts | 1 - packages/junior/src/chat/agent/index.ts | 56 +- packages/junior/src/chat/agent/resume.ts | 2 - .../junior/src/chat/conversations/README.md | 15 +- .../junior/src/chat/conversations/history.ts | 22 +- .../src/chat/conversations/projection.ts | 99 +- .../src/chat/conversations/sql/purge.ts | 9 + .../src/chat/conversations/sql/turns.ts | 145 +++ .../junior/src/chat/conversations/turns.ts | 28 + packages/junior/src/chat/db.ts | 10 + packages/junior/src/chat/local/runner.ts | 1 - packages/junior/src/chat/runtime/README.md | 6 +- .../src/chat/runtime/agent-continue-runner.ts | 11 +- .../junior/src/chat/runtime/reply-executor.ts | 7 +- .../junior/src/chat/runtime/slack-resume.ts | 1 - .../src/chat/services/context-compaction.ts | 7 +- .../src/chat/services/turn-session-record.ts | 23 - .../junior/src/chat/state/turn-session.ts | 42 - packages/junior/src/db/schema.ts | 3 + .../src/db/schema/conversation-turns.ts | 33 + .../conversation-transcripts-sql.test.ts | 145 ++- .../component/conversations/retention.test.ts | 19 + .../services/context-compaction.test.ts | 4 +- .../services/turn-session-record.test.ts | 19 +- .../integration/agent-continue-slack.test.ts | 14 +- .../integration/dashboard-reporting.test.ts | 21 +- .../runtime/agent-run-model-handoff.test.ts | 1 - .../runtime/agent-run-provider-retry.test.ts | 2 +- 33 files changed, 1716 insertions(+), 144 deletions(-) create mode 100644 packages/junior/migrations/0005_solid_franklin_storm.sql create mode 100644 packages/junior/migrations/meta/0005_snapshot.json create mode 100644 packages/junior/src/chat/conversations/sql/turns.ts create mode 100644 packages/junior/src/chat/conversations/turns.ts create mode 100644 packages/junior/src/db/schema/conversation-turns.ts 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/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 7a898709a..5c7e493f4 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1783976272513, "tag": "0004_big_malice", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783985660251, + "tag": "0005_solid_franklin_storm", + "breakpoints": true } ] } 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 6281b9c75..997fdde5f 100644 --- a/packages/junior/src/api/conversations/detail.query.ts +++ b/packages/junior/src/api/conversations/detail.query.ts @@ -6,6 +6,7 @@ import { import { buildConversationDetail } from "./detail-projection"; import { readConversationRecordFromSql } from "./list.query"; import type { ConversationDetailReport } from "./schema"; +import { getConversationTurnStore } from "@/chat/db"; async function readLatestRun( conversationId: string, @@ -22,6 +23,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, @@ -36,7 +51,10 @@ export async function readConversationDetailFromSql( }), readLatestRun(conversationId), ]); - const modelId = latestRun?.startingModelId ?? latestRun?.modelId; + const modelId = await readLatestTurnModelId( + conversationId, + latestRun?.sessionId ?? record.conversation.execution.runId, + ); return { ...report, ...(modelId ? { modelId } : {}), diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts index 008119488..cbef26fab 100644 --- a/packages/junior/src/chat/agent-dispatch/runner.ts +++ b/packages/junior/src/chat/agent-dispatch/runner.ts @@ -482,7 +482,6 @@ export async function runAgentDispatchSlice( sessionId: turnId, sliceId: 1, messages: reply.piMessages, - startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, durationMs: reply.diagnostics.durationMs, usage: reply.diagnostics.usage, diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 7e02ecbf2..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, @@ -285,16 +286,53 @@ 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, - modelProfile: activeModelProfile, + 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 = { conversationId: sessionConversationId, @@ -343,17 +381,6 @@ async function executeAgentRunInPrivacyContext( const skillSandbox = new SkillSandbox(availableSkills, activeSkills); // ── Turn session record ────────────────────────────────────────── - const { - sessionRecordState, - resumedFromSessionRecord, - currentSliceId, - existingSessionRecord, - } = await restoreSessionRecord(routing); - if (existingSessionRecord?.startingModelId) { - startingModelId = existingSessionRecord.startingModelId; - } else if (existingSessionRecord?.modelId) { - startingModelId = existingSessionRecord.modelId; - } const fixedReasoningConfigured = policy.reasoningPolicy?.mode === "fixed" || (!policy.reasoningPolicy && Boolean(botConfig.reasoningLevel)); @@ -394,7 +421,6 @@ async function executeAgentRunInPrivacyContext( sessionConversationId, sessionId, sessionRecordState, - startingModelId, startedAtMs: replyStartedAtMs, surface, }); diff --git a/packages/junior/src/chat/agent/resume.ts b/packages/junior/src/chat/agent/resume.ts index 2ffcb836e..bd5468600 100644 --- a/packages/junior/src/chat/agent/resume.ts +++ b/packages/junior/src/chat/agent/resume.ts @@ -51,7 +51,6 @@ interface ResumeStateArgs { sessionConversationId?: string; sessionId?: string; sessionRecordState: LoadedSessionRecordState; - startingModelId: string; startedAtMs: number; surface?: AgentTurnSurface; } @@ -95,7 +94,6 @@ export function createResumeState(args: ResumeStateArgs) { sessionId: args.sessionId!, loadedSkillNames: args.getLoadedSkillNames(), logContext: args.logContext, - startingModelId: args.startingModelId, modelId: args.getModelId(), ...(args.getReasoningLevel() ? { reasoningLevel: args.getReasoningLevel() } diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index c09449127..35090a3fe 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -10,9 +10,11 @@ agent steps, compaction boundaries, search, retention, and legacy import. - 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. @@ -28,10 +30,11 @@ The schemas and migrations under `sql/` are authoritative. `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 while exact provider model - IDs continue to resolve through the current host model catalog. -- Initial context epochs use the stored model profile. Handoff, - compaction, and rollback epochs retain their own durable model binding. +- 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 diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index 9096bf1c1..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. @@ -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"), @@ -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 4436ef731..9ab1ba76f 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 @@ -234,8 +239,13 @@ export async function loadConversationProjection( /** Open the configured initial epoch before a conversation's first model request. */ export async function openConversationProjection( - args: ScopedConversation & { modelId: string; modelProfile: ModelProfile }, -): 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); @@ -247,22 +257,59 @@ export async function openConversationProjection( step.entry.type === "pi_message", ) ) { - return projection; + 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: args.modelProfile, - modelId: args.modelId, + modelProfile: args.initialModelProfile, + modelId: args.resolveModelId(args.initialModelProfile), messages: [], }); - return { - messages: projection.messages, - provenance: projection.provenance, - modelProfile: args.modelProfile, - 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 }; } /** @@ -327,7 +374,7 @@ function messageTimestamp(message: PiMessage): number { */ 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 +391,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 +407,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 +418,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/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 af9a15ded..eb5a032a0 100644 --- a/packages/junior/src/chat/db.ts +++ b/packages/junior/src/chat/db.ts @@ -6,6 +6,8 @@ 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"; @@ -19,6 +21,7 @@ let current: store: ConversationStore & ConversationExecutionProfileStore; stepStore: AgentStepStore; messageStore: ConversationMessageStore; + turnStore: ConversationTurnStore; searchStore: ConversationSearchStore; } | undefined; @@ -59,6 +62,7 @@ export function getSqlExecutor(): JuniorSqlExecutor { store: createSqlStore(db), stepStore: createSqlAgentStepStore(db), messageStore: createSqlConversationMessageStore(db), + turnStore: createSqlConversationTurnStore(db), searchStore: createSqlConversationSearchStore(db), }; } @@ -94,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/local/runner.ts b/packages/junior/src/chat/local/runner.ts index bc0e6ad44..ad1b4bf58 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -348,7 +348,6 @@ export async function runLocalAgentTurn( sessionId: turnId, sliceId: 1, messages: reply.piMessages, - startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, durationMs: reply.diagnostics.durationMs, usage: reply.diagnostics.usage, diff --git a/packages/junior/src/chat/runtime/README.md b/packages/junior/src/chat/runtime/README.md index 2c86f8a71..c481560d0 100644 --- a/packages/junior/src/chat/runtime/README.md +++ b/packages/junior/src/chat/runtime/README.md @@ -37,10 +37,12 @@ this directory owns product orchestration around it. reasoning, additional instructions, and tool restrictions. - Profile tool policy can narrow host authority but cannot grant tools, credentials, destinations, or other capabilities. -- Turn-session metadata keeps the starting model separate from the latest - active model so reporting does not rewrite the start after a handoff. +- 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 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/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 1c9e410fd..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), @@ -1484,7 +1486,6 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { sessionId: turnId, sliceId: 1, messages: reply.piMessages, - startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, logContext: { threadId, diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index 77b280c71..507e2ae52 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -497,7 +497,6 @@ export async function resumeSlackTurn( conversationId: replyContext.routing.correlation.conversationId, sessionId: replyContext.routing.correlation.turnId, allMessages: reply.piMessages, - startingModelId: reply.diagnostics.startingModelId, modelId: reply.diagnostics.modelId, currentDurationMs: reply.diagnostics.durationMs, currentUsage: reply.diagnostics.usage, 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-session-record.ts b/packages/junior/src/chat/services/turn-session-record.ts index 4667b3c3e..148727b39 100644 --- a/packages/junior/src/chat/services/turn-session-record.ts +++ b/packages/junior/src/chat/services/turn-session-record.ts @@ -139,7 +139,6 @@ export async function persistRunningSessionRecord(args: { trailingMessageProvenance?: PiMessageProvenance[]; loadedSkillNames?: string[]; logContext: SessionRecordLogContext; - startingModelId?: string; modelId: string; actor?: Actor; reasoningLevel?: string; @@ -181,9 +180,6 @@ export async function persistRunningSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - ...(args.startingModelId - ? { startingModelId: args.startingModelId } - : {}), modelId: args.modelId, ...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel } : {}), ...((args.actor ?? latestSessionRecord?.actor) @@ -240,7 +236,6 @@ export async function persistCompletedSessionRecord(args: { allMessages: PiMessage[]; loadedSkillNames?: string[]; logContext: SessionRecordLogContext; - startingModelId?: string; modelId: string; actor?: Actor; reasoningLevel?: string; @@ -300,7 +295,6 @@ export async function persistCompletedSessionRecord(args: { args.loadedSkillNames ?? latestSessionRecord?.loadedSkillNames, } : {}), - ...(args.startingModelId ? { startingModelId: args.startingModelId } : {}), modelId, ...(reasoningLevel ? { reasoningLevel } : {}), ...((args.actor ?? latestSessionRecord?.actor) @@ -333,7 +327,6 @@ export async function completeDeliveredTurn(args: { loadedSkillNames?: string[]; logContext: SessionRecordLogContext; messages: PiMessage[]; - startingModelId?: string; modelId: string; actor?: Actor; reasoningLevel?: string; @@ -357,7 +350,6 @@ export async function completeDeliveredTurn(args: { allMessages: args.messages, loadedSkillNames: args.loadedSkillNames, logContext: args.logContext, - startingModelId: args.startingModelId, modelId: args.modelId, actor: args.actor, reasoningLevel: args.reasoningLevel, @@ -381,7 +373,6 @@ export async function persistAuthPauseSessionRecord(args: { source?: Source; messages: PiMessage[]; loadedSkillNames?: string[]; - startingModelId?: string; modelId: string; errorMessage: string; logContext: SessionRecordLogContext; @@ -431,9 +422,6 @@ export async function persistAuthPauseSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - ...(args.startingModelId - ? { startingModelId: args.startingModelId } - : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { @@ -481,7 +469,6 @@ export async function persistTimeoutSessionRecord(args: { source?: Source; messages: PiMessage[]; loadedSkillNames?: string[]; - startingModelId?: string; modelId: string; errorMessage: string; logContext: SessionRecordLogContext; @@ -539,9 +526,6 @@ export async function persistTimeoutSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - ...(args.startingModelId - ? { startingModelId: args.startingModelId } - : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { @@ -583,9 +567,6 @@ export async function persistTimeoutSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - ...(args.startingModelId - ? { startingModelId: args.startingModelId } - : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { @@ -632,7 +613,6 @@ export async function persistYieldSessionRecord(args: { source?: Source; messages: PiMessage[]; loadedSkillNames?: string[]; - startingModelId?: string; modelId: string; errorMessage: string; logContext: SessionRecordLogContext; @@ -681,9 +661,6 @@ export async function persistYieldSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - ...(args.startingModelId - ? { startingModelId: args.startingModelId } - : {}), modelId: args.modelId, ...((args.reasoningLevel ?? latestSessionRecord?.reasoningLevel) ? { diff --git a/packages/junior/src/chat/state/turn-session.ts b/packages/junior/src/chat/state/turn-session.ts index 1afef6674..928f39ac9 100644 --- a/packages/junior/src/chat/state/turn-session.ts +++ b/packages/junior/src/chat/state/turn-session.ts @@ -65,8 +65,6 @@ export interface AgentTurnSessionRecord { errorMessage?: string; lastProgressAtMs: number; loadedSkillNames?: string[]; - /** Exact model used when this turn began. */ - startingModelId?: string; /** Model active at the latest persisted boundary. */ modelId?: string; reasoningLevel?: string; @@ -154,7 +152,6 @@ const agentTurnSessionSummarySchema = z source: sourceSchema.optional(), lastProgressAtMs: nonNegativeNumberSchema, loadedSkillNames: z.array(z.string()).optional(), - startingModelId: z.string().min(1).optional(), modelId: z.string().min(1).optional(), reasoningLevel: z.string().min(1).optional(), actor: actorSchema.optional(), @@ -179,23 +176,6 @@ const storedAgentTurnSessionRecordSchema = agentTurnSessionSummarySchema }) .strict() satisfies z.ZodType; -function resolveStartingModelId(args: { - existing?: Pick; - modelId?: string; - startingModelId?: string; -}): string | undefined { - if (args.existing?.startingModelId) { - return args.existing.startingModelId; - } - if (args.existing?.modelId) { - return args.existing.modelId; - } - if (args.startingModelId) { - return args.startingModelId; - } - return args.modelId; -} - function agentTurnSessionKey( conversationId: string, sessionId: string, @@ -327,9 +307,6 @@ function materializeAgentTurnSessionRecord( ...(stored.loadedSkillNames ? { loadedSkillNames: stored.loadedSkillNames } : {}), - ...(stored.startingModelId - ? { startingModelId: stored.startingModelId } - : {}), ...(stored.modelId ? { modelId: stored.modelId } : {}), ...(stored.reasoningLevel ? { reasoningLevel: stored.reasoningLevel } : {}), ...(stored.actor ? { actor: stored.actor } : {}), @@ -402,7 +379,6 @@ function buildStoredRecord(args: { committedSeq: number; lastProgressAtMs?: number; loadedSkillNames?: string[]; - startingModelId?: string; modelId?: string; previousVersion?: number; reasoningLevel?: string; @@ -443,7 +419,6 @@ function buildStoredRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - ...(args.startingModelId ? { startingModelId: args.startingModelId } : {}), ...(args.modelId ? { modelId: args.modelId } : {}), ...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel } : {}), ...(args.resumeReason ? { resumeReason: args.resumeReason } : {}), @@ -546,9 +521,6 @@ async function updateAgentTurnSessionState(args: { ...(args.existing.loadedSkillNames ? { loadedSkillNames: args.existing.loadedSkillNames } : {}), - ...(args.existing.startingModelId - ? { startingModelId: args.existing.startingModelId } - : {}), ...(args.existing.modelId ? { modelId: args.existing.modelId } : {}), ...(args.existing.reasoningLevel ? { reasoningLevel: args.existing.reasoningLevel } @@ -582,7 +554,6 @@ export async function upsertAgentTurnSessionRecord(args: { source?: Source; lastProgressAtMs?: number; loadedSkillNames?: string[]; - startingModelId?: string; modelId: string; conversationStore?: ConversationStore; sessionId: string; @@ -607,11 +578,6 @@ export async function upsertAgentTurnSessionRecord(args: { args.sessionId, ); const ttlMs = Math.max(1, args.ttlMs ?? AGENT_TURN_SESSION_TTL_MS); - const startingModelId = resolveStartingModelId({ - existing: existingRecord, - modelId: args.modelId, - startingModelId: args.startingModelId, - }); // Attribute new user input to the turn's actor as an instruction; the step // store reuses committed provenance for the unchanged prefix and defaults the // rest to context. Platform-neutral so local identities are preserved too. @@ -680,7 +646,6 @@ export async function upsertAgentTurnSessionRecord(args: { ...(args.loadedSkillNames ? { loadedSkillNames: args.loadedSkillNames } : {}), - startingModelId, modelId: args.modelId, ...((args.reasoningLevel ?? existingRecord?.reasoningLevel) ? { @@ -727,7 +692,6 @@ export async function recordAgentTurnSessionSummary(args: { source?: Source; lastProgressAtMs?: number; loadedSkillNames?: string[]; - startingModelId?: string; modelId?: string; conversationStore?: ConversationStore; actor?: Actor; @@ -747,11 +711,6 @@ export async function recordAgentTurnSessionSummary(args: { ); const nowMs = Date.now(); const ttlMs = Math.max(1, args.ttlMs ?? AGENT_TURN_SESSION_TTL_MS); - const startingModelId = resolveStartingModelId({ - existing, - modelId: args.modelId, - startingModelId: args.startingModelId, - }); const summary: AgentTurnSessionSummary = { version: existing?.version ?? 0, ...((args.channelName ?? existing?.channelName) @@ -783,7 +742,6 @@ export async function recordAgentTurnSessionSummary(args: { : existing?.loadedSkillNames ? { loadedSkillNames: existing.loadedSkillNames } : {}), - ...(startingModelId ? { startingModelId } : {}), ...((args.modelId ?? existing?.modelId) ? { modelId: args.modelId ?? existing?.modelId } : {}), 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/tests/component/conversation-transcripts-sql.test.ts b/packages/junior/tests/component/conversation-transcripts-sql.test.ts index 159b4fa7c..a4e5591b3 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, @@ -72,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", @@ -109,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", [ @@ -145,8 +161,9 @@ it("opens an explicit initial epoch without dropping earlier host facts", async await expect( openConversationProjection({ conversationId, - modelId: "openai/gpt-5.4", - modelProfile: "standard", + initialModelProfile: "standard", + refreshModelBinding: true, + resolveModelId: () => "openai/gpt-5.4", }), ).resolves.toMatchObject({ messages: [], @@ -173,6 +190,73 @@ 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("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, @@ -238,7 +322,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(5); + expect(applied?.count).toBe(6); } finally { await fixture.close(); } @@ -297,6 +381,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/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 3dbe2c81c..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,23 @@ 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", - startingModelId: "openai/gpt-5.6-sol", modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", piMessages: [userMessage("continue")], @@ -1217,7 +1228,6 @@ describe("persistAuthPauseSessionRecord", () => { await expect( getAgentTurnSessionRecord(conversationId, sessionId), ).resolves.toMatchObject({ - startingModelId: "openai/gpt-5.6", modelId: "openai/gpt-5.6-sol", reasoningLevel: "low", }); @@ -1226,7 +1236,6 @@ describe("persistAuthPauseSessionRecord", () => { (summary) => summary.sessionId === sessionId, ), ).toMatchObject({ - startingModelId: "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/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 44573a52d..00ba19f05 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -326,8 +326,28 @@ describe("dashboard reporting", () => { 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,6 @@ describe("dashboard reporting", () => { state: "completed", cumulativeDurationMs: 1_200, cumulativeUsage: { inputTokens: 100, outputTokens: 20 }, - startingModelId: "openai/gpt-5.5", modelId: "openai/gpt-5.6-sol", reasoningLevel: "high", piMessages: [ 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 3dedb7fb6..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 @@ -506,7 +506,6 @@ describe("executeAgentRun model handoff", () => { expect(outcome.status).toBe("suspended"); const record = await getAgentTurnSessionRecord(conversationId, sessionId); expect(record).toMatchObject({ - startingModelId: observations.initialModelId, modelId: "openai/gpt-5.6-sol", state: "awaiting_resume", }); 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 ?? [], From 1e731b3399df05b48773e6701bc81f5c23c0045a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 17:18:46 -0700 Subject: [PATCH 10/12] fix(runtime): Bind legacy history to profile When message steps predate explicit context-epoch markers, open a bound replacement epoch with the conversation execution profile. This prevents the projection default from silently selecting the standard model role. Refs #876 Co-Authored-By: OpenAI Codex --- .../src/chat/conversations/projection.ts | 31 ++++++++++++---- .../conversation-transcripts-sql.test.ts | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 9ab1ba76f..5c1c2ab05 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -250,13 +250,30 @@ export async function openConversationProjection( 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", - ) - ) { + 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; diff --git a/packages/junior/tests/component/conversation-transcripts-sql.test.ts b/packages/junior/tests/component/conversation-transcripts-sql.test.ts index a4e5591b3..7ff765f29 100644 --- a/packages/junior/tests/component/conversation-transcripts-sql.test.ts +++ b/packages/junior/tests/component/conversation-transcripts-sql.test.ts @@ -239,6 +239,43 @@ it("opens a new epoch when a profile resolves to a new model", async () => { ]); }); +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({ From ab984f11038b8301fff22c23e124b8dd2f0f5da5 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 17:23:50 -0700 Subject: [PATCH 11/12] fix(reporting): Select the current conversation turn Use SQL execution.run_id to select the turn whose settings the dashboard reports. Do not borrow a model from an older completed turn when the current run has only sparse operational metadata. Retain a bounded compatibility fallback to session model metadata for conversations created before durable turn rows shipped. Refs #876 Co-Authored-By: OpenAI Codex --- .../src/api/conversations/detail.query.ts | 30 +++++++--- .../integration/dashboard-reporting.test.ts | 59 ++++++++++++++++--- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/junior/src/api/conversations/detail.query.ts b/packages/junior/src/api/conversations/detail.query.ts index 997fdde5f..12959f860 100644 --- a/packages/junior/src/api/conversations/detail.query.ts +++ b/packages/junior/src/api/conversations/detail.query.ts @@ -1,5 +1,6 @@ import { logException } from "@/chat/logging"; import { + getAgentTurnSessionRecord, listBoundedAgentTurnSessionSummariesForConversation, type AgentTurnSessionSummary, } from "@/chat/state/turn-session"; @@ -8,13 +9,25 @@ import { readConversationRecordFromSql } from "./list.query"; import type { ConversationDetailReport } from "./schema"; import { getConversationTurnStore } from "@/chat/db"; -async function readLatestRun( +async function readReportedRun( conversationId: string, + runId: string | undefined, ): Promise { try { - return ( - await listBoundedAgentTurnSessionSummariesForConversation(conversationId) - ).find((summary) => summary.modelId || summary.reasoningLevel); + const summaries = + await listBoundedAgentTurnSessionSummariesForConversation(conversationId); + if (!runId) { + return summaries.find( + (summary) => summary.modelId || summary.reasoningLevel, + ); + } + const summary = summaries.find( + (candidate) => candidate.sessionId === runId, + ); + if (summary?.modelId || summary?.reasoningLevel) { + return summary; + } + return await getAgentTurnSessionRecord(conversationId, runId); } catch (error) { logException(error, "conversation_execution_settings_read_failed", { conversationId, @@ -49,15 +62,18 @@ export async function readConversationDetailFromSql( ...record, usage: record.usage ?? undefined, }), - readLatestRun(conversationId), + readReportedRun(conversationId, record.conversation.execution.runId), ]); const modelId = await readLatestTurnModelId( conversationId, - latestRun?.sessionId ?? record.conversation.execution.runId, + record.conversation.execution.runId ?? latestRun?.sessionId, ); + // 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, - ...(modelId ? { modelId } : {}), + ...(reportedModelId ? { modelId: reportedModelId } : {}), ...(latestRun?.reasoningLevel ? { reasoningLevel: latestRun.reasoningLevel } : {}), diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 00ba19f05..9559b7913 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -322,7 +322,7 @@ 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"); @@ -413,13 +413,6 @@ describe("dashboard reporting", () => { }, ] as PiMessage[], }); - await recordAgentTurnSessionSummary({ - conversationId: "slack:C1:222", - sessionId: "turn-running", - sliceId: 1, - state: "running", - }); - const report = await readConversationDetailFromSql("slack:C1:222"); expect(report).toMatchObject({ cumulativeDurationMs: 1_200, @@ -476,6 +469,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"); From a54b8a94c58edffb576a7265fae5c40f6daebbd6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 17:31:26 -0700 Subject: [PATCH 12/12] fix(reporting): Use the durable turn identity Select the newest turn-session summary as the reporting authority. Mailbox execution run IDs can identify worker attempts rather than user turns, so they are only a fallback for recordless runs. Cover a mailbox run ID that differs from the durable turn ID. Refs #876 Co-Authored-By: OpenAI Codex --- .../src/api/conversations/detail.query.ts | 17 ++++++----------- .../integration/dashboard-reporting.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/junior/src/api/conversations/detail.query.ts b/packages/junior/src/api/conversations/detail.query.ts index 12959f860..5bce4fb0b 100644 --- a/packages/junior/src/api/conversations/detail.query.ts +++ b/packages/junior/src/api/conversations/detail.query.ts @@ -11,23 +11,18 @@ import { getConversationTurnStore } from "@/chat/db"; async function readReportedRun( conversationId: string, - runId: string | undefined, ): Promise { try { const summaries = await listBoundedAgentTurnSessionSummariesForConversation(conversationId); - if (!runId) { - return summaries.find( - (summary) => summary.modelId || summary.reasoningLevel, - ); + const summary = summaries[0]; + if (!summary) { + return undefined; } - const summary = summaries.find( - (candidate) => candidate.sessionId === runId, - ); if (summary?.modelId || summary?.reasoningLevel) { return summary; } - return await getAgentTurnSessionRecord(conversationId, runId); + return await getAgentTurnSessionRecord(conversationId, summary.sessionId); } catch (error) { logException(error, "conversation_execution_settings_read_failed", { conversationId, @@ -62,11 +57,11 @@ export async function readConversationDetailFromSql( ...record, usage: record.usage ?? undefined, }), - readReportedRun(conversationId, record.conversation.execution.runId), + readReportedRun(conversationId), ]); const modelId = await readLatestTurnModelId( conversationId, - record.conversation.execution.runId ?? latestRun?.sessionId, + 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. diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 9559b7913..7ebf9ee46 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -413,6 +413,17 @@ describe("dashboard reporting", () => { }, ] as PiMessage[], }); + const { requestConversationWork } = + await import("@/chat/task-execution/store"); + await requestConversationWork({ + conversationId: "slack:C1:222", + destination: { + platform: "slack", + teamId: "T1", + channelId: "C1", + }, + nowMs: Date.now(), + }); const report = await readConversationDetailFromSql("slack:C1:222"); expect(report).toMatchObject({ cumulativeDurationMs: 1_200,