From 1ea796a5db69e9a73e04f079c301f1d1a6c97c26 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:47:46 +0000 Subject: [PATCH 1/5] feat(agent): deliver child results to parent mailboxes Persist terminal agent-invocation parent notification state and append one idempotent parent-mailbox result so idle parents can resume from durable child work. Heartbeat repair retries pending parent delivery, and Slack synthetic result messages force a parent turn without depending on resource-event subscriptions. Fixes #878 Refs #883 Co-Authored-By: David Cramer --- ...3_agent_invocation_parent_notification.sql | 2 + .../junior/migrations/meta/0023_snapshot.json | 2213 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../src/chat/agent-dispatch/heartbeat.ts | 37 +- .../src/chat/agent-invocations/README.md | 18 +- .../src/chat/agent-invocations/actor.ts | 43 + .../agent-invocations/parent-notification.ts | 141 ++ .../src/chat/agent-invocations/store.ts | 45 + .../src/chat/agent-invocations/types.ts | 16 +- .../junior/src/chat/agent-invocations/work.ts | 24 + packages/junior/src/chat/app/production.ts | 2 + .../src/chat/runtime/agent-continue-runner.ts | 7 + .../src/chat/runtime/conversation-message.ts | 16 +- .../junior/src/chat/runtime/reply-executor.ts | 26 +- .../junior/src/chat/runtime/slack-runtime.ts | 13 +- .../src/chat/task-execution/slack-work.ts | 122 +- packages/junior/src/cli/chat.ts | 16 +- .../junior/src/db/schema/agent-invocations.ts | 13 + .../component/conversations/retention.test.ts | 1 + .../agent-invocation-concurrency.test.ts | 3 + .../integration/agent-invocation-work.test.ts | 115 +- scripts/file-length-exceptions.mjs | 2 + 22 files changed, 2853 insertions(+), 29 deletions(-) create mode 100644 packages/junior/migrations/0023_agent_invocation_parent_notification.sql create mode 100644 packages/junior/migrations/meta/0023_snapshot.json create mode 100644 packages/junior/src/chat/agent-invocations/actor.ts create mode 100644 packages/junior/src/chat/agent-invocations/parent-notification.ts diff --git a/packages/junior/migrations/0023_agent_invocation_parent_notification.sql b/packages/junior/migrations/0023_agent_invocation_parent_notification.sql new file mode 100644 index 0000000000..b362ce7a3e --- /dev/null +++ b/packages/junior/migrations/0023_agent_invocation_parent_notification.sql @@ -0,0 +1,2 @@ +ALTER TABLE "junior_agent_invocations" ADD COLUMN "parent_notification_status" text;--> statement-breakpoint +CREATE INDEX "junior_agent_invocations_parent_notification_idx" ON "junior_agent_invocations" USING btree ("parent_notification_status","terminal_at"); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0023_snapshot.json b/packages/junior/migrations/meta/0023_snapshot.json new file mode 100644 index 0000000000..6f1122a484 --- /dev/null +++ b/packages/junior/migrations/meta/0023_snapshot.json @@ -0,0 +1,2213 @@ +{ + "id": "8daab8e3-2687-414a-ab95-72d7276fa8c2", + "prevId": "108c9745-ff35-44a0-a964-26e2809e2023", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_notification_status": { + "name": "parent_notification_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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 + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_notification_idx": { + "name": "junior_agent_invocations_parent_notification_idx", + "columns": [ + { + "expression": "parent_notification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "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_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "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 + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "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 + }, + "root_conversation_id": { + "name": "root_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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "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": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_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" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_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_event_tasks": { + "name": "junior_event_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "task_json": { + "name": "task_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_event_tasks_team_idx": { + "name": "junior_event_tasks_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_event_tasks_match_idx": { + "name": "junior_event_tasks_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "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_scheduler_runs": { + "name": "junior_scheduler_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_runs_task_status_idx": { + "name": "junior_scheduler_runs_task_status_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_runs_status_idx": { + "name": "junior_scheduler_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_tasks": { + "name": "junior_scheduler_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_tasks_creator_idx": { + "name": "junior_scheduler_tasks_creator_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creator_slack_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_creator_identity_idx": { + "name": "junior_scheduler_tasks_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted' AND \"junior_scheduler_tasks\".\"creator_identity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_team_status_idx": { + "name": "junior_scheduler_tasks_team_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_run_now_due_idx": { + "name": "junior_scheduler_tasks_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_next_run_due_idx": { + "name": "junior_scheduler_tasks_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_task_executions": { + "name": "junior_task_executions", + "schema": "", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executed_at_ms": { + "name": "executed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_task_executions_task_time_idx": { + "name": "junior_task_executions_task_time_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_time_kind_idx": { + "name": "junior_task_executions_time_kind_idx", + "columns": [ + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_conversation_time_idx": { + "name": "junior_task_executions_conversation_time_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_task_executions", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_task_executions_kind_namespace_execution_id_pk": { + "name": "junior_task_executions_kind_namespace_execution_id_pk", + "columns": [ + "kind", + "namespace", + "execution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_task_executions_kind_check": { + "name": "junior_task_executions_kind_check", + "value": "\"junior_task_executions\".\"kind\" in ('scheduled', 'event')" + }, + "junior_task_executions_status_check": { + "name": "junior_task_executions_status_check", + "value": "\"junior_task_executions\".\"status\" in ('blocked', 'completed', 'failed')" + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 11706888ae..a728e31b4a 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1786128720003, "tag": "0022_task_execution_status", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1786149789087, + "tag": "0023_agent_invocation_parent_notification", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index 93c2df6a8f..3474b12e4c 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -5,7 +5,11 @@ import { runScheduledTaskHeartbeat } from "@/chat/scheduled-tasks/heartbeat"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; import { createHeartbeatContext } from "./context"; -import { listPendingAgentInvocationMailboxAppends } from "@/chat/agent-invocations/store"; +import { + listPendingAgentInvocationMailboxAppends, + listPendingAgentInvocationParentNotifications, +} from "@/chat/agent-invocations/store"; +import { notifyParentOfAgentInvocationResult } from "@/chat/agent-invocations/parent-notification"; import { enqueueAgentInvocation } from "@/chat/agent-invocations/work"; import { confirmDispatchMailboxAppend, @@ -20,6 +24,7 @@ const DEFAULT_PLUGIN_LIMIT = 25; const PLUGIN_HEARTBEAT_TIMEOUT_MS = 25_000; const DISPATCH_MAILBOX_APPEND_LIMIT = 100; const AGENT_INVOCATION_MAILBOX_APPEND_LIMIT = 100; +const AGENT_INVOCATION_PARENT_NOTIFICATION_LIMIT = 100; async function runWithTimeout( promise: Promise, timeoutMs: number, @@ -151,6 +156,32 @@ export async function recoverPendingAgentInvocationMailboxAppends(args: { } } +/** Repair terminal child results that stopped before parent-mailbox delivery. */ +export async function recoverPendingAgentInvocationParentNotifications(args: { + conversationWorkQueue: ConversationWorkQueue; + nowMs: number; +}): Promise { + const invocations = await listPendingAgentInvocationParentNotifications( + AGENT_INVOCATION_PARENT_NOTIFICATION_LIMIT, + ); + for (const invocation of invocations) { + try { + await notifyParentOfAgentInvocationResult(invocation, { + nowMs: args.nowMs, + queue: args.conversationWorkQueue, + }); + } catch (error) { + logException( + error, + "agent.invocation.parent_notification.recovery.failed", + { + "app.agent.invocation_id": invocation.invocationId, + }, + ); + } + } +} + /** Run the core heartbeat phases. */ export async function runHeartbeat(args: { conversationWorkQueue?: ConversationWorkQueue; @@ -169,6 +200,10 @@ export async function runHeartbeat(args: { conversationWorkQueue: queue, nowMs: args.nowMs, }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: args.nowMs, + }); try { const dispatchCount = await runScheduledTaskHeartbeat({ conversationWorkQueue: queue, diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md index 2fb1c1cf84..d9d76f11d6 100644 --- a/packages/junior/src/chat/agent-invocations/README.md +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -43,13 +43,19 @@ conversation tree. projects onto the invocation. Empty resume wakes never become final delivery attempts, so unrecoverable stranded work must not throw and requeue forever. 6. Completion writes the session record first, then projects the immutable - result or error onto the invocation. -7. The heartbeat repairs invocations left in `mailboxStatus: "pending"`. + result or error onto the invocation with + `parentNotificationStatus: "pending"`. +7. The worker appends one idempotent parent-mailbox result message and marks + `parentNotificationStatus: "notified"`. Queue delivery is only a wake-up + hint; parent delivery remains discoverable from the invocation while pending. +8. The heartbeat repairs child mailbox appends left in + `mailboxStatus: "pending"` and parent notifications left in + `parentNotificationStatus: "pending"`. The child conversation has no provider destination. Each invocation carries the actor, credential context, source, and destination that bound its tool -execution. Child output is an internal result; provider delivery remains owned -by the parent-facing runtime. +execution. Child output is an internal result delivered into the parent mailbox; +provider-visible replies remain owned by the parent-facing runtime. ## Current Boundary @@ -67,7 +73,7 @@ hand off models, start interactive OAuth pauses, or spawn further children. TODO(#881, #883): children may still need a way to force interactive auth when a delegated tool requires credentials the parent can already request. -This slice does not yet expose result recovery, inject child results into a -parent turn, support recursive children, or implement cancellation. Those +Parent result delivery is automatic and durable. This slice does not yet expose +`getAgentResult`, support recursive children, or implement cancellation. Those behaviors should build on the invocation record rather than introducing another scheduler or execution loop. diff --git a/packages/junior/src/chat/agent-invocations/actor.ts b/packages/junior/src/chat/agent-invocations/actor.ts new file mode 100644 index 0000000000..4bfb7b494b --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/actor.ts @@ -0,0 +1,43 @@ +/** + * Execution identity for agent-invocation result turns. + * + * Parent-result mailbox messages are synthetic (not human-authored). They run + * as this system principal for credentials and attribution. Live delivery stamps + * the author id / raw marker; resume rebuilds the same system actor from those + * durable markers. + */ +import type { Actor } from "@/chat/actor"; + +/** Synthetic Slack author id stamped on agent-invocation result messages. */ +export const AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID = "UJRNAGENT"; + +/** System execution actor for every agent-invocation result turn. */ +export const AGENT_INVOCATION_RESULT_SYSTEM_ACTOR = { + platform: "system", + name: "agent-invocation-result", +} as const satisfies Actor; + +/** Whether a durable conversation message is an agent-invocation result input. */ +export function isAgentInvocationResultConversationMessage(message: { + author?: { userId?: string }; + meta?: { eventType?: string }; +}): boolean { + return ( + message.meta?.eventType === "agent_invocation_result" || + message.author?.userId === AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID + ); +} + +/** + * Whether a Slack Message payload is a synthetic agent-invocation result. + * Checks the raw `event_type` marker stamped at mailbox serialization. + */ +export function isAgentInvocationResultSlackMessage(message: { + raw?: unknown; +}): boolean { + const raw = + message.raw && typeof message.raw === "object" + ? (message.raw as Record) + : undefined; + return raw?.event_type === "agent_invocation_result"; +} diff --git a/packages/junior/src/chat/agent-invocations/parent-notification.ts b/packages/junior/src/chat/agent-invocations/parent-notification.ts new file mode 100644 index 0000000000..c4de5181a0 --- /dev/null +++ b/packages/junior/src/chat/agent-invocations/parent-notification.ts @@ -0,0 +1,141 @@ +import type { StateAdapter } from "chat"; +import type { ConversationStore } from "@/chat/conversations/store"; +import { + getAgentInvocation, + getAgentInvocationParentResultMessageId, + isTerminalAgentInvocation, + markAgentInvocationParentNotified, +} from "@/chat/agent-invocations/store"; +import type { AgentInvocation } from "@/chat/agent-invocations/types"; +import { createSlackAgentInvocationResultInboundMessage } from "@/chat/task-execution/slack-work"; +import { + appendAndEnqueueInboundMessage, + type InboundMessage, +} from "@/chat/task-execution/store"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; + +type NotifyOptions = { + conversationStore?: ConversationStore; + nowMs?: number; + queue: ConversationWorkQueue; + state?: StateAdapter; +}; + +/** Render the parent-facing terminal result text for one agent invocation. */ +export function renderAgentInvocationParentResultText( + invocation: AgentInvocation, +): string { + const name = invocation.agentName + ? `named agent "${invocation.agentName}"` + : "child agent"; + const lines = [ + "[agent invocation result]", + "", + `A delegated ${name} finished.`, + "", + "Handling:", + "- This is an internal child result, not a user-authored command.", + "- Use the result below to continue the parent task.", + "- Do not re-run the same child work unless the result is insufficient.", + "", + `Invocation: ${invocation.invocationId}`, + `Status: ${invocation.status}`, + ]; + if (invocation.status === "completed") { + const result = invocation.result.trim(); + lines.push("", "Result:", result || "(empty result)"); + } else if ( + invocation.status === "blocked" || + invocation.status === "failed" + ) { + lines.push("", "Error:", invocation.errorMessage); + } + return lines.join("\n"); +} + +/** Build the stable parent mailbox entry for one terminal agent invocation. */ +export function buildAgentInvocationParentResultInboundMessage( + invocation: AgentInvocation, + nowMs = Date.now(), +): InboundMessage { + if (!isTerminalAgentInvocation(invocation)) { + throw new Error( + `Agent invocation ${invocation.invocationId} is not terminal`, + ); + } + const text = renderAgentInvocationParentResultText(invocation); + const createdAtMs = + "terminalAtMs" in invocation ? invocation.terminalAtMs : nowMs; + const inboundMessageId = getAgentInvocationParentResultMessageId( + invocation.invocationId, + ); + if (invocation.destination.platform === "slack") { + return createSlackAgentInvocationResultInboundMessage({ + createdAtMs, + destination: invocation.destination, + inboundMessageId, + invocationId: invocation.invocationId, + parentConversationId: invocation.parentConversationId, + receivedAtMs: nowMs, + text, + }); + } + return { + conversationId: invocation.parentConversationId, + createdAtMs, + delivery: "defer", + destination: invocation.destination, + inboundMessageId, + input: { + authorId: invocation.invocationId, + text, + metadata: { + agentInvocationId: invocation.invocationId, + kind: "agent_invocation_result", + status: invocation.status, + }, + }, + receivedAtMs: nowMs, + source: "internal", + }; +} + +/** + * Deliver one terminal child result into the parent mailbox once. + * + * Queue delivery is only a wake-up hint. The durable parent-notification status + * remains pending until the mailbox append path succeeds so heartbeat repair + * can retry from invocation state. + */ +export async function notifyParentOfAgentInvocationResult( + invocation: AgentInvocation, + options: NotifyOptions, +): Promise { + if (!isTerminalAgentInvocation(invocation)) { + return; + } + if ( + !("parentNotificationStatus" in invocation) || + invocation.parentNotificationStatus !== "pending" + ) { + return; + } + const nowMs = options.nowMs ?? Date.now(); + const latest = await getAgentInvocation(invocation.invocationId); + if ( + !latest || + !isTerminalAgentInvocation(latest) || + !("parentNotificationStatus" in latest) || + latest.parentNotificationStatus !== "pending" + ) { + return; + } + await appendAndEnqueueInboundMessage({ + message: buildAgentInvocationParentResultInboundMessage(latest, nowMs), + conversationStore: options.conversationStore, + nowMs, + queue: options.queue, + state: options.state, + }); + await markAgentInvocationParentNotified(latest.invocationId, nowMs); +} diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts index 14e78f36bd..4663f6a652 100644 --- a/packages/junior/src/chat/agent-invocations/store.ts +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -69,6 +69,13 @@ export function getAgentInvocationMessageId(invocationId: string): string { return `agent-invocation:${invocationId}:input`; } +/** Return the stable parent-mailbox identity for one terminal agent invocation. */ +export function getAgentInvocationParentResultMessageId( + invocationId: string, +): string { + return `agent-invocation:${invocationId}:result`; +} + function bindingFromRow( row: typeof juniorAgentBindings.$inferSelect, ): AgentBinding { @@ -99,6 +106,9 @@ function invocationFromRow( invocationId: row.invocationId, mailboxStatus: row.mailboxStatus, parentConversationId: row.parentConversationId, + ...(row.parentNotificationStatus + ? { parentNotificationStatus: row.parentNotificationStatus } + : {}), ...(row.reasoningLevel ? { reasoningLevel: row.reasoningLevel } : {}), ...(row.result !== null ? { result: row.result } : {}), source: row.source, @@ -414,6 +424,7 @@ export async function completeAgentInvocation( status: args.status, result: args.status === "completed" ? args.result : null, errorMessage: args.status === "completed" ? null : args.errorMessage, + parentNotificationStatus: "pending", terminalAt: new Date(nowMs), updatedAt: new Date(nowMs), }) @@ -429,6 +440,40 @@ export async function completeAgentInvocation( return await getAgentInvocation(args.invocationId); } +/** List terminal invocations whose parent-mailbox delivery still needs repair. */ +export async function listPendingAgentInvocationParentNotifications( + limit = 100, +): Promise { + const rows = await getSqlExecutor() + .db() + .select() + .from(juniorAgentInvocations) + .where(eq(juniorAgentInvocations.parentNotificationStatus, "pending")) + .orderBy(asc(juniorAgentInvocations.terminalAt)) + .limit(limit); + return rows.map(invocationFromRow); +} + +/** Record that the parent mailbox accepted the terminal result once. */ +export async function markAgentInvocationParentNotified( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ + parentNotificationStatus: "notified", + updatedAt: new Date(nowMs), + }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + eq(juniorAgentInvocations.parentNotificationStatus, "pending"), + ), + ); +} + /** Return whether an invocation already owns its immutable terminal result. */ export function isTerminalAgentInvocation( invocation: AgentInvocation, diff --git a/packages/junior/src/chat/agent-invocations/types.ts b/packages/junior/src/chat/agent-invocations/types.ts index 56e39be7ed..e3891d3a41 100644 --- a/packages/junior/src/chat/agent-invocations/types.ts +++ b/packages/junior/src/chat/agent-invocations/types.ts @@ -8,6 +8,7 @@ import { credentialContextSchema } from "@/chat/credentials/context"; import { TURN_REASONING_LEVELS } from "@/chat/reasoning-level"; import { AGENT_INVOCATION_MAILBOX_STATUSES, + AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES, AGENT_INVOCATION_STATUSES, } from "@/db/schema/agent-invocations"; @@ -22,6 +23,10 @@ const agentInvocationMailboxStatusSchema = z.enum( AGENT_INVOCATION_MAILBOX_STATUSES, ); +const agentInvocationParentNotificationStatusSchema = z.enum( + AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES, +); + export const agentBindingSchema = z .object({ childConversationId: exactStringSchema, @@ -49,19 +54,22 @@ const agentInvocationBaseSchema = z }) .strict(); +const terminalAgentInvocationBaseSchema = agentInvocationBaseSchema.extend({ + parentNotificationStatus: agentInvocationParentNotificationStatusSchema, + terminalAtMs: z.number().finite(), +}); + export const agentInvocationSchema = z.discriminatedUnion("status", [ agentInvocationBaseSchema.extend({ status: z.enum(["pending", "running", "awaiting_resume"]), }), - agentInvocationBaseSchema.extend({ + terminalAgentInvocationBaseSchema.extend({ result: z.string(), status: z.literal("completed"), - terminalAtMs: z.number().finite(), }), - agentInvocationBaseSchema.extend({ + terminalAgentInvocationBaseSchema.extend({ errorMessage: z.string(), status: z.enum(["blocked", "failed"]), - terminalAtMs: z.number().finite(), }), ]); diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 9741ddafea..fb51e19086 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -52,6 +52,7 @@ import { markAgentInvocationMailboxAppended, markAgentInvocationRunning, } from "./store"; +import { notifyParentOfAgentInvocationResult } from "./parent-notification"; import type { AgentInvocation, CreateAgentInvocationInput } from "./types"; const agentInvocationMailboxMetadataSchema = z @@ -306,7 +307,23 @@ function isInvocationInputCommitLost(error: unknown): boolean { /** Build the invocation consumer that advances work through the shared runner. */ export function createAgentInvocationWorker(options: { agentRunner: AgentRunner; + conversationStore?: ConversationStore; + queue?: ConversationWorkQueue; + state?: StateAdapter; }) { + const deliverParentResult = async ( + invocation: AgentInvocation, + ): Promise => { + if (!options.queue) { + return; + } + await notifyParentOfAgentInvocationResult(invocation, { + conversationStore: options.conversationStore, + queue: options.queue, + state: options.state, + }); + }; + return async ( context: ConversationWorkerContext, invocationId: string, @@ -351,6 +368,7 @@ export function createAgentInvocationWorker(options: { } if (isTerminalAgentInvocation(invocation)) { await persistTerminalLifecycle(invocation); + await deliverParentResult(invocation); await acknowledge(); return { status: "completed" }; } @@ -361,6 +379,7 @@ export function createAgentInvocationWorker(options: { const projected = await projectTerminalSession(invocation); if (projected && isTerminalAgentInvocation(projected)) { await persistTerminalLifecycle(projected); + await deliverParentResult(projected); await acknowledge(); return { status: "completed" }; } @@ -398,6 +417,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -465,6 +485,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -480,6 +501,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -497,6 +519,7 @@ export function createAgentInvocationWorker(options: { }); if (terminal) { await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); } await acknowledge(); return { status: "completed" }; @@ -551,6 +574,7 @@ export function createAgentInvocationWorker(options: { ); } await persistTerminalLifecycle(terminal); + await deliverParentResult(terminal); await acknowledge(); return { status: "completed" }; }; diff --git a/packages/junior/src/chat/app/production.ts b/packages/junior/src/chat/app/production.ts index 2c7de029b6..e082081303 100644 --- a/packages/junior/src/chat/app/production.ts +++ b/packages/junior/src/chat/app/production.ts @@ -167,6 +167,8 @@ export function createProductionConversationWorkOptions(options: { run: routeAgentInvocationWork({ invocationWorker: createAgentInvocationWorker({ agentRunner, + conversationStore, + queue, }), fallbackWorker: providerWorker, }), diff --git a/packages/junior/src/chat/runtime/agent-continue-runner.ts b/packages/junior/src/chat/runtime/agent-continue-runner.ts index d6c14d8470..8ba13ee049 100644 --- a/packages/junior/src/chat/runtime/agent-continue-runner.ts +++ b/packages/junior/src/chat/runtime/agent-continue-runner.ts @@ -76,6 +76,10 @@ import { isResourceEventConversationMessage, RESOURCE_EVENT_SYSTEM_ACTOR, } from "@/chat/resource-events/actor"; +import { + AGENT_INVOCATION_RESULT_SYSTEM_ACTOR, + isAgentInvocationResultConversationMessage, +} from "@/chat/agent-invocations/actor"; import type { AgentRunResult } from "@/chat/services/turn-result"; import type { AgentRunner } from "@/chat/runtime/agent-runner"; import type { AgentRunRouting } from "@/chat/agent/request"; @@ -314,6 +318,9 @@ async function resolveResumeExecutionIdentity(args: { if (!actor && isResourceEventConversationMessage(args.userMessage)) { actor = RESOURCE_EVENT_SYSTEM_ACTOR; } + if (!actor && isAgentInvocationResultConversationMessage(args.userMessage)) { + actor = AGENT_INVOCATION_RESULT_SYSTEM_ACTOR; + } if (!actor && args.userMessage.author?.userId) { actor = await resolveSlackResumeUserActor({ conversationId: args.conversationId, diff --git a/packages/junior/src/chat/runtime/conversation-message.ts b/packages/junior/src/chat/runtime/conversation-message.ts index 53226217a4..05b3bb069b 100644 --- a/packages/junior/src/chat/runtime/conversation-message.ts +++ b/packages/junior/src/chat/runtime/conversation-message.ts @@ -16,13 +16,19 @@ interface ConversationMessageInput { text: string; } -function resourceEventType(entry: Message): string | undefined { +function syntheticEventType(entry: Message): string | undefined { if (!entry.raw || typeof entry.raw !== "object") return undefined; const raw = entry.raw as Record; - return raw.event_type === "resource_event" && + if ( + raw.event_type === "resource_event" && typeof raw.resource_event_type === "string" - ? raw.resource_event_type - : undefined; + ) { + return raw.resource_event_type; + } + if (raw.event_type === "agent_invocation_result") { + return "agent_invocation_result"; + } + return undefined; } function resolveMessageText(args: ConversationMessageInput): string { @@ -59,7 +65,7 @@ export function toConversationMessage( }, meta: { attachmentCount: args.entry.attachments.length, - eventType: resourceEventType(args.entry), + eventType: syntheticEventType(args.entry), explicitMention: args.explicitMention, imageAttachmentCount: imageAttachmentCount > 0 ? imageAttachmentCount : undefined, diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index af47babb87..83e579416e 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -109,6 +109,10 @@ import { isResourceEventSlackMessage, RESOURCE_EVENT_SYSTEM_ACTOR, } from "@/chat/resource-events/actor"; +import { + AGENT_INVOCATION_RESULT_SYSTEM_ACTOR, + isAgentInvocationResultSlackMessage, +} from "@/chat/agent-invocations/actor"; import type { AgentContinueRequest } from "@/chat/services/agent-continue"; import { ConversationTurnBoundaryError, @@ -282,7 +286,10 @@ function queuedInstructionProvenance( queued: QueuedTurnMessage, teamId: string, ): ConversationMessageProvenance { - if (isResourceEventSlackMessage(queued.message)) { + if ( + isResourceEventSlackMessage(queued.message) || + isAgentInvocationResultSlackMessage(queued.message) + ) { return contextProvenance; } const identity = getMessageActorIdentity(queued.message); @@ -536,7 +543,11 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { }; await Promise.all( (options.queuedMessages ?? []) - .filter((queued) => !isResourceEventSlackMessage(queued.message)) + .filter( + (queued) => + !isResourceEventSlackMessage(queued.message) && + !isAgentInvocationResultSlackMessage(queued.message), + ) .map((queued) => ensureSlackMessageActorIdentity( queued.message, @@ -557,6 +568,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { } else if (isResourceEventSlackMessage(message)) { executionActor = RESOURCE_EVENT_SYSTEM_ACTOR; credentialContext = credentialContextForActor(executionActor); + } else if (isAgentInvocationResultSlackMessage(message)) { + executionActor = AGENT_INVOCATION_RESULT_SYSTEM_ACTOR; + credentialContext = credentialContextForActor(executionActor); } else { executionActor = await ensureSlackMessageActorIdentity( message, @@ -658,9 +672,11 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { attachments, { threadId, - actorId: isResourceEventSlackMessage(queued.message) - ? undefined - : queued.message.author.userId, + actorId: + isResourceEventSlackMessage(queued.message) || + isAgentInvocationResultSlackMessage(queued.message) + ? undefined + : queued.message.author.userId, channelId, runId, conversation: preparedState.conversation, diff --git a/packages/junior/src/chat/runtime/slack-runtime.ts b/packages/junior/src/chat/runtime/slack-runtime.ts index c23f2353de..698483f9a6 100644 --- a/packages/junior/src/chat/runtime/slack-runtime.ts +++ b/packages/junior/src/chat/runtime/slack-runtime.ts @@ -47,6 +47,7 @@ import { } from "@/chat/runtime/turn-input"; import { getMessageActorIdentity } from "@/chat/services/message-actor-identity"; import { isResourceEventSlackMessage } from "@/chat/resource-events/actor"; +import { isAgentInvocationResultSlackMessage } from "@/chat/agent-invocations/actor"; import type { FailConversationTurnInput } from "@/chat/conversations/turn-lifecycle"; export interface AssistantLifecycleEvent { @@ -901,7 +902,11 @@ export function createSlackTurnRuntime< const runId = deps.getRunId(thread, message); const isResourceEventNotification = isResourceEventSlackMessage(message); - const actorId = isResourceEventNotification + const isAgentInvocationResultNotification = + isAgentInvocationResultSlackMessage(message); + const isSystemNotification = + isResourceEventNotification || isAgentInvocationResultNotification; + const actorId = isSystemNotification ? undefined : message.author.userId; const turnContext = logContext({ @@ -945,7 +950,7 @@ export function createSlackTurnRuntime< const turnIsExplicitMention = Boolean(message.isMention) || queuedMessages.some((queued) => queued.explicitMention); - const preflightDecision = isResourceEventNotification + const preflightDecision = isSystemNotification ? undefined : getSubscribedReplyPreflightDecision({ botUserName: deps.assistantUserName, @@ -985,7 +990,9 @@ export function createSlackTurnRuntime< const decision: SubscribedReplyDecision = isResourceEventNotification ? { shouldReply: true, reason: "resource_event" } - : await deps.decideSubscribedReply({ + : isAgentInvocationResultNotification + ? { shouldReply: true, reason: "agent_invocation_result" } + : await deps.decideSubscribedReply({ rawText: combinedText.rawText, text: combinedText.userText, conversationContext: diff --git a/packages/junior/src/chat/task-execution/slack-work.ts b/packages/junior/src/chat/task-execution/slack-work.ts index 4635601e2f..0ed0074761 100644 --- a/packages/junior/src/chat/task-execution/slack-work.ts +++ b/packages/junior/src/chat/task-execution/slack-work.ts @@ -38,6 +38,10 @@ import { isResourceEventSlackMessage, RESOURCE_EVENT_SLACK_AUTHOR_ID, } from "@/chat/resource-events/actor"; +import { + AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + isAgentInvocationResultSlackMessage, +} from "@/chat/agent-invocations/actor"; import { createSlackDestination, requireSlackDestination, @@ -410,6 +414,12 @@ const slackConversationMessageMetadataSchema = z.union([ .strict(), }) .strict(), + slackConversationMessageMetadataBaseSchema + .extend({ + agentInvocationId: z.string().min(1), + kind: z.literal("agent_invocation_result"), + }) + .strict(), ]); export type SlackConversationMessageMetadata = z.output< @@ -544,6 +554,113 @@ function slackSerializedResourceEventMessage(input: { }; } +interface SlackAgentInvocationResultInboundInput { + createdAtMs: number; + destination: { + channelId: string; + platform: "slack"; + teamId: string; + }; + inboundMessageId: string; + invocationId: string; + parentConversationId: string; + receivedAtMs: number; + text: string; +} + +/** + * Serialize a synthetic agent-invocation result without a native Slack message + * timestamp so Slack Web API calls cannot target the internal id. + */ +function slackSerializedAgentInvocationResultMessage(input: { + channelId: string; + id: string; + text: string; + threadTs: string; + timestampIso: string; +}): SerializedMessage { + return { + _type: "chat:Message", + attachments: [], + author: { + userId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + userName: "junior-agent", + fullName: "Junior agent", + isBot: true, + isMe: false, + }, + formatted: { type: "root", children: [] }, + id: input.id, + metadata: { + dateSent: input.timestampIso, + edited: false, + }, + raw: { + channel: input.channelId, + event_type: "agent_invocation_result", + thread_ts: input.threadTs, + type: "message", + user: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + }, + text: input.text, + threadId: `slack:${input.channelId}:${input.threadTs}`, + }; +} + +/** Create a Slack mailbox record for one terminal agent-invocation result. */ +export function createSlackAgentInvocationResultInboundMessage( + input: SlackAgentInvocationResultInboundInput, +): InboundMessage { + const slack = parseSlackConversationId(input.parentConversationId); + if (!slack) { + throw new Error( + "Agent invocation result delivery currently requires a Slack conversation", + ); + } + if (input.destination.channelId !== slack.channelId) { + throw new Error( + "Agent invocation destination does not match Slack parent conversation", + ); + } + const timestampIso = new Date(input.createdAtMs).toISOString(); + const message = slackSerializedAgentInvocationResultMessage({ + channelId: slack.channelId, + id: input.inboundMessageId, + text: input.text, + threadTs: slack.threadTs, + timestampIso, + }); + const thread = slackSerializedThread({ + channelId: slack.channelId, + message, + threadTs: slack.threadTs, + }); + return { + conversationId: input.parentConversationId, + createdAtMs: input.createdAtMs, + destination: input.destination, + inboundMessageId: input.inboundMessageId, + delivery: "defer", + source: "internal", + receivedAtMs: input.receivedAtMs, + input: { + text: input.text, + authorId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + metadata: { + agentInvocationId: input.invocationId, + installation: { + teamId: input.destination.teamId, + }, + kind: "agent_invocation_result", + platform: "slack", + route: "subscribed", + thread, + message, + } satisfies SlackConversationMessageMetadata, + }, + }; +} + /** Create a Slack mailbox record for a subscribed resource-event notification. */ export function createSlackResourceEventInboundMessage( input: SlackResourceEventInboundInput, @@ -670,7 +787,10 @@ async function bindSlackActorIdentities(args: { }): Promise { const byAuthorId = new Map(); for (const message of args.messages) { - if (isResourceEventSlackMessage(message)) { + if ( + isResourceEventSlackMessage(message) || + isAgentInvocationResultSlackMessage(message) + ) { continue; } const authorId = requireSlackAuthorId(message); diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 7640b33010..e2d4c8ff6c 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -259,11 +259,25 @@ async function prepareLocalChatRun( throw new Error("Local agent runner is not ready"); } const run = routeAgentInvocationWork({ - fallbackWorker: async () => { + fallbackWorker: async (context) => { + // Local parents are not mailbox-driven. Parent result delivery still + // appends an idempotent mailbox entry and may wake this queue; ack it + // without starting another local turn. + if (context.attempt.messages.length > 0) { + const onlyParentResults = context.attempt.messages.every( + (entry) => + entry.input.metadata?.kind === "agent_invocation_result", + ); + if (onlyParentResults) { + await context.attempt.ack(); + return { status: "completed" as const }; + } + } throw new Error("Local child queue received non-invocation work"); }, invocationWorker: createAgentInvocationWorker({ agentRunner, + queue: localConversationWork.queue, }), }); await processConversationWork(message, { diff --git a/packages/junior/src/db/schema/agent-invocations.ts b/packages/junior/src/db/schema/agent-invocations.ts index 2944851dad..f98bdf1f4e 100644 --- a/packages/junior/src/db/schema/agent-invocations.ts +++ b/packages/junior/src/db/schema/agent-invocations.ts @@ -27,6 +27,12 @@ export const AGENT_INVOCATION_MAILBOX_STATUSES = [ "appended", ] as const; +/** Parent-mailbox delivery for one terminal agent invocation result. */ +export const AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES = [ + "pending", + "notified", +] as const; + export const juniorAgentBindings = pgTable( "junior_agent_bindings", { @@ -76,6 +82,9 @@ export const juniorAgentInvocations = pgTable( mailboxStatus: text("mailbox_status") .$type<(typeof AGENT_INVOCATION_MAILBOX_STATUSES)[number]>() .notNull(), + parentNotificationStatus: text("parent_notification_status").$type< + (typeof AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES)[number] + >(), result: text("result"), errorMessage: text("error_message"), createdAt: timestamptz("created_at").notNull(), @@ -88,5 +97,9 @@ export const juniorAgentInvocations = pgTable( table.mailboxStatus, table.createdAt, ), + index("junior_agent_invocations_parent_notification_idx").on( + table.parentNotificationStatus, + table.terminalAt, + ), ], ); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 7850711efb..33a1ea0194 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -263,6 +263,7 @@ describe("retention purge job", () => { invocationId: "agent-invocation:retained", mailboxStatus: "appended", parentConversationId: "root", + parentNotificationStatus: "notified", result: "private delegated result", source: { conversationId: "root", diff --git a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts index 9130aeb873..39953bcbfd 100644 --- a/packages/junior/tests/integration/agent-invocation-concurrency.test.ts +++ b/packages/junior/tests/integration/agent-invocation-concurrency.test.ts @@ -108,6 +108,9 @@ async function createHarness( fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 2ab44c3822..0b4321b269 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -5,6 +5,7 @@ import { completeAgentInvocation, createAgentInvocation, getAgentInvocation, + getAgentInvocationParentResultMessageId, getAgentInvocationTurnId, markAgentInvocationRunning, } from "@/chat/agent-invocations/store"; @@ -22,8 +23,14 @@ import { createSqlStore } from "@/chat/conversations/sql/store"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; -import { recoverPendingAgentInvocationMailboxAppends } from "@/chat/agent-dispatch/heartbeat"; -import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS } from "@/chat/task-execution/store"; +import { + recoverPendingAgentInvocationMailboxAppends, + recoverPendingAgentInvocationParentNotifications, +} from "@/chat/agent-dispatch/heartbeat"; +import { + CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, + getConversationWorkState, +} from "@/chat/task-execution/store"; import type { ConversationWorkerContext } from "@/chat/task-execution/worker"; import { getAgentTurnSessionRecord, @@ -300,6 +307,9 @@ describe("agent invocation conversation work", () => { fallbackWorker, invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); const queueMessage = queue.takeMessage(); @@ -327,10 +337,30 @@ describe("agent invocation conversation work", () => { getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ mailboxStatus: "appended", + parentNotificationStatus: "notified", result: "Durable child result", status: "completed", terminalAtMs: expect.any(Number), }); + const parentWork = await getConversationWorkState({ + conversationId: parentConversationId, + state, + }); + expect(parentWork?.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inboundMessageId: getAgentInvocationParentResultMessageId( + created.invocationId, + ), + source: "internal", + }), + ]), + ); + expect( + queue.sentRecords().some( + (record) => record.conversationId === parentConversationId, + ), + ).toBe(true); const completed = await getAgentInvocation(created.invocationId); await completeAgentInvocation({ errorMessage: "late conflicting failure", @@ -387,6 +417,9 @@ describe("agent invocation conversation work", () => { }); }), }, + conversationStore, + queue, + state, }); const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), @@ -417,6 +450,7 @@ describe("agent invocation conversation work", () => { await expect( getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ + parentNotificationStatus: "notified", result: "Resumed child result", status: "completed", }); @@ -515,6 +549,7 @@ describe("agent invocation conversation work", () => { }); const worker = createAgentInvocationWorker({ agentRunner: { run }, + // No queue: this path only asserts child terminalization, not parent delivery. }); // Empty resume wakes set isFinalAttempt false, so unrecoverable stranded // recovery must terminalize without relying on final-attempt handling. @@ -620,6 +655,9 @@ describe("agent invocation conversation work", () => { fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); @@ -701,6 +739,9 @@ describe("agent invocation conversation work", () => { fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), invocationWorker: createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }), }); @@ -744,6 +785,9 @@ describe("agent invocation conversation work", () => { const deliveryAttempts: Array = []; const invocationWorker = createAgentInvocationWorker({ agentRunner: { run }, + conversationStore, + queue, + state, }); const route = routeAgentInvocationWork({ fallbackWorker: vi.fn(async () => ({ status: "completed" as const })), @@ -781,6 +825,7 @@ describe("agent invocation conversation work", () => { getAgentInvocation(created.invocationId), ).resolves.toMatchObject({ errorMessage: "model unavailable", + parentNotificationStatus: "notified", status: "failed", }); } finally { @@ -798,7 +843,10 @@ describe("agent invocation conversation work", () => { }); const run = vi.fn(); const ack = vi.fn(); - const worker = createAgentInvocationWorker({ agentRunner: { run } }); + const worker = createAgentInvocationWorker({ + agentRunner: { run }, + // No queue: invariant failure path only needs child terminalization. + }); const context = { attempt: { ack, @@ -842,4 +890,65 @@ describe("agent invocation conversation work", () => { await fixture.close(); } }); + + it("repairs pending parent notification delivery once", async () => { + const { conversationStore, fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "parent-notify-repair-1", + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Parent should see this.", + status: "completed", + }); + expect(terminal).toMatchObject({ + parentNotificationStatus: "pending", + status: "completed", + }); + + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 5_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "notified", + }); + const parentWork = await getConversationWorkState({ + conversationId: parentConversationId, + state, + }); + const resultId = getAgentInvocationParentResultMessageId( + created.invocationId, + ); + expect( + parentWork?.messages.filter( + (message) => message.inboundMessageId === resultId, + ), + ).toHaveLength(1); + expect( + queue + .sentRecords() + .filter((record) => record.conversationId === parentConversationId), + ).toHaveLength(1); + } finally { + await fixture.close(); + } + }); + }); diff --git a/scripts/file-length-exceptions.mjs b/scripts/file-length-exceptions.mjs index ae61e3a9cd..4700621038 100644 --- a/scripts/file-length-exceptions.mjs +++ b/scripts/file-length-exceptions.mjs @@ -36,6 +36,8 @@ export const fileLengthExceptions = { "Existing Slack runtime; split by runtime phase.", "packages/junior/src/chat/state/turn-session.ts": "Existing turn session persistence; split by persistence concern.", + "packages/junior/src/chat/task-execution/slack-work.ts": + "Existing Slack mailbox adapter; split synthetic ingress builders next.", "packages/junior/src/chat/task-execution/state.ts": "Existing mailbox and lease store; split without separating shared locks.", "packages/junior/src/cli/check.ts": From af094ebb1cdc50c54e5cb3a6cff45dd6e4c52431 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:10:26 +0000 Subject: [PATCH 2/5] test(agent): fix parent-delivery CI lint and migration count Co-Authored-By: David Cramer --- packages/junior/tests/component/scheduled-tasks-sql.test.ts | 2 +- packages/junior/tests/integration/agent-invocation-work.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/junior/tests/component/scheduled-tasks-sql.test.ts b/packages/junior/tests/component/scheduled-tasks-sql.test.ts index 7c9deedbea..7df72501f7 100644 --- a/packages/junior/tests/component/scheduled-tasks-sql.test.ts +++ b/packages/junior/tests/component/scheduled-tasks-sql.test.ts @@ -175,7 +175,7 @@ describe("scheduled-task SQL storage", () => { await expect(migrateSchema(fixture.sql)).resolves.toMatchObject({ existing: 16, - migrated: 7, + migrated: 8, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null; diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 0b4321b269..beea8e99ef 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -892,7 +892,7 @@ describe("agent invocation conversation work", () => { }); it("repairs pending parent notification delivery once", async () => { - const { conversationStore, fixture } = await prepareParentConversation(); + const { fixture } = await prepareParentConversation(); const queue = createConversationWorkQueueTestAdapter(); const state = getStateAdapter(); await state.connect(); From a7f3e5ddf36ffe0c2e779ac5b1b2f471694de340 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:48:54 +0000 Subject: [PATCH 3/5] ref(agent): align parent-result delivery with synthetic event shape Share one synthetic inbound builder with resource events, restore parent authority from the invocation, target non-Slack parents correctly, and terminalize permanent notification failures. Co-Authored-By: David Cramer --- .../junior/src/chat/agent-dispatch/work.ts | 38 ++- .../src/chat/agent-invocations/README.md | 11 + .../src/chat/agent-invocations/actor.ts | 73 ++++- .../agent-invocations/parent-notification.ts | 83 +++-- .../src/chat/agent-invocations/store.ts | 20 ++ .../junior/src/chat/resource-events/README.md | 9 +- .../src/chat/resource-events/notification.ts | 4 +- .../src/chat/runtime/agent-continue-runner.ts | 20 +- .../junior/src/chat/runtime/reply-executor.ts | 23 +- .../src/chat/task-execution/slack-work.ts | 270 +---------------- .../chat/task-execution/synthetic-inbound.ts | 284 ++++++++++++++++++ .../junior/src/db/schema/agent-invocations.ts | 1 + .../integration/agent-invocation-work.test.ts | 99 +++++- scripts/file-length-exceptions.mjs | 2 - 14 files changed, 607 insertions(+), 330 deletions(-) create mode 100644 packages/junior/src/chat/task-execution/synthetic-inbound.ts diff --git a/packages/junior/src/chat/agent-dispatch/work.ts b/packages/junior/src/chat/agent-dispatch/work.ts index 8e3fa5b969..2aba3c97a4 100644 --- a/packages/junior/src/chat/agent-dispatch/work.ts +++ b/packages/junior/src/chat/agent-dispatch/work.ts @@ -59,6 +59,13 @@ const agentDispatchMailboxMetadataSchema = z }) .strict(); +const agentInvocationResultMailboxMetadataSchema = z + .object({ + agentInvocationId: z.string().min(1), + kind: z.literal("agent_invocation_result"), + }) + .passthrough(); + export const AGENT_DISPATCH_MAX_AGE_MS = 24 * 60 * 60 * 1000; interface DurableDispatchTurnResult extends DispatchTurnResult { @@ -198,11 +205,25 @@ function dispatchIdFromMessages( return ids.values().next().value; } +function onlyAgentInvocationResultMessages( + messages: readonly InboundMessage[], +): boolean { + return ( + messages.length > 0 && + messages.every((message) => + agentInvocationResultMailboxMetadataSchema.safeParse(message.input.metadata) + .success, + ) + ); +} + /** * Resolve a dispatch from concrete mailbox metadata or the active turn. * * Provider source and conversation-id conventions are deliberately not - * execution routing authority. + * execution routing authority. Parent-result wakes for a dispatch conversation + * still resolve through the active/durable dispatch so delivery does not fall + * through to the Slack worker with the wrong conversation identity. */ export async function resolveAgentDispatchId( context: ConversationWorkerContext, @@ -211,7 +232,10 @@ export async function resolveAgentDispatchId( if (mailboxDispatchId) { return mailboxDispatchId; } - if (context.attempt.messages.length > 0) { + if ( + context.attempt.messages.length > 0 && + !onlyAgentInvocationResultMessages(context.attempt.messages) + ) { return undefined; } @@ -451,7 +475,15 @@ export function createAgentDispatchConversationWorker( } acknowledged = true; }; - if (isTerminalDispatchStatus(dispatch.status)) { + // Child results wake the parent mailbox. The durable result lives on the + // invocation; this wake is queue ownership only. Ack it, then advance any + // still-open dispatch the same way an empty resume wake would. + if (onlyAgentInvocationResultMessages(context.attempt.messages)) { + await acknowledge(); + if (isTerminalDispatchStatus(dispatch.status)) { + return { status: "completed" }; + } + } else if (isTerminalDispatchStatus(dispatch.status)) { await acknowledge(); return { status: "completed" }; } diff --git a/packages/junior/src/chat/agent-invocations/README.md b/packages/junior/src/chat/agent-invocations/README.md index d9d76f11d6..f9bf8c0fca 100644 --- a/packages/junior/src/chat/agent-invocations/README.md +++ b/packages/junior/src/chat/agent-invocations/README.md @@ -48,10 +48,21 @@ conversation tree. 7. The worker appends one idempotent parent-mailbox result message and marks `parentNotificationStatus: "notified"`. Queue delivery is only a wake-up hint; parent delivery remains discoverable from the invocation while pending. + Permanent builder/destination failures move to + `parentNotificationStatus: "failed"` so they stay queryable without infinite + retries. 8. The heartbeat repairs child mailbox appends left in `mailboxStatus: "pending"` and parent notifications left in `parentNotificationStatus: "pending"`. +Parent-result delivery shares the synthetic inbound shape with resource events +(`task-execution/synthetic-inbound.ts`): stable inbound id, kind + durable +reference metadata, rendered text, and a Slack envelope only when the parent +destination is Slack. It does **not** use resource-event subscriptions. The +parent conversation id is always the mailbox identity; authority for the parent +turn is restored from the invocation's actor + credentialContext rather than a +synthetic system principal. + The child conversation has no provider destination. Each invocation carries the actor, credential context, source, and destination that bound its tool execution. Child output is an internal result delivered into the parent mailbox; diff --git a/packages/junior/src/chat/agent-invocations/actor.ts b/packages/junior/src/chat/agent-invocations/actor.ts index 4bfb7b494b..4635c4e211 100644 --- a/packages/junior/src/chat/agent-invocations/actor.ts +++ b/packages/junior/src/chat/agent-invocations/actor.ts @@ -1,22 +1,22 @@ /** - * Execution identity for agent-invocation result turns. + * Identity helpers for agent-invocation result turns. * - * Parent-result mailbox messages are synthetic (not human-authored). They run - * as this system principal for credentials and attribution. Live delivery stamps - * the author id / raw marker; resume rebuilds the same system actor from those - * durable markers. + * Parent-result mailbox messages are synthetic (not human-authored). Message + * markers identify the kind; execution authority is restored from the durable + * agent invocation (parent actor + credentialContext), not a synthetic system + * principal. Resource events keep a system principal because they are external + * watches without a parent run binding. */ import type { Actor } from "@/chat/actor"; +import { + credentialContextForActor, + type CredentialContext, +} from "@/chat/credentials/context"; +import { getAgentInvocation } from "@/chat/agent-invocations/store"; /** Synthetic Slack author id stamped on agent-invocation result messages. */ export const AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID = "UJRNAGENT"; -/** System execution actor for every agent-invocation result turn. */ -export const AGENT_INVOCATION_RESULT_SYSTEM_ACTOR = { - platform: "system", - name: "agent-invocation-result", -} as const satisfies Actor; - /** Whether a durable conversation message is an agent-invocation result input. */ export function isAgentInvocationResultConversationMessage(message: { author?: { userId?: string }; @@ -41,3 +41,54 @@ export function isAgentInvocationResultSlackMessage(message: { : undefined; return raw?.event_type === "agent_invocation_result"; } + +/** Read the durable agent-invocation id stamped on a synthetic result message. */ +export function getAgentInvocationIdFromSlackMessage(message: { + raw?: unknown; +}): string | undefined { + const raw = + message.raw && typeof message.raw === "object" + ? (message.raw as Record) + : undefined; + return typeof raw?.agent_invocation_id === "string" && + raw.agent_invocation_id.trim().length > 0 + ? raw.agent_invocation_id + : undefined; +} + +/** Parse the durable parent-result message id back to an invocation id. */ +export function getAgentInvocationIdFromParentResultMessageId( + messageId: string | undefined, +): string | undefined { + if (!messageId?.startsWith("agent-invocation:") || !messageId.endsWith(":result")) { + return undefined; + } + const invocationId = messageId.slice( + "agent-invocation:".length, + -":result".length, + ); + return invocationId.trim().length > 0 ? invocationId : undefined; +} + +/** Restore parent run authority for one terminal agent-invocation result. */ +export async function resolveAgentInvocationResultAuthority(args: { + messageId?: string; + raw?: unknown; +}): Promise<{ actor: Actor; credentialContext: CredentialContext } | undefined> { + const invocationId = + getAgentInvocationIdFromSlackMessage(args) ?? + getAgentInvocationIdFromParentResultMessageId(args.messageId); + if (!invocationId) { + return undefined; + } + const invocation = await getAgentInvocation(invocationId); + if (!invocation) { + return undefined; + } + return { + actor: invocation.actor, + credentialContext: + invocation.credentialContext ?? + credentialContextForActor(invocation.actor), + }; +} diff --git a/packages/junior/src/chat/agent-invocations/parent-notification.ts b/packages/junior/src/chat/agent-invocations/parent-notification.ts index c4de5181a0..efd403b79e 100644 --- a/packages/junior/src/chat/agent-invocations/parent-notification.ts +++ b/packages/junior/src/chat/agent-invocations/parent-notification.ts @@ -4,15 +4,17 @@ import { getAgentInvocation, getAgentInvocationParentResultMessageId, isTerminalAgentInvocation, + markAgentInvocationParentNotificationFailed, markAgentInvocationParentNotified, } from "@/chat/agent-invocations/store"; import type { AgentInvocation } from "@/chat/agent-invocations/types"; -import { createSlackAgentInvocationResultInboundMessage } from "@/chat/task-execution/slack-work"; +import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import { appendAndEnqueueInboundMessage, type InboundMessage, } from "@/chat/task-execution/store"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import { logException } from "@/chat/logging"; type NotifyOptions = { conversationStore?: ConversationStore; @@ -21,6 +23,14 @@ type NotifyOptions = { state?: StateAdapter; }; +/** Permanent parent-delivery failures that should not keep retrying. */ +export class PermanentAgentInvocationParentNotificationError extends Error { + constructor(message: string) { + super(message); + this.name = "PermanentAgentInvocationParentNotificationError"; + } +} + /** Render the parent-facing terminal result text for one agent invocation. */ export function renderAgentInvocationParentResultText( invocation: AgentInvocation, @@ -53,13 +63,19 @@ export function renderAgentInvocationParentResultText( return lines.join("\n"); } -/** Build the stable parent mailbox entry for one terminal agent invocation. */ +/** + * Build the stable parent mailbox entry for one terminal agent invocation. + * + * Shape matches other internal wakes (dispatch / child invocation): kind + + * durable reference id + rendered text. Slack parents also carry the synthetic + * envelope resource events use so the shared Slack worker can restore the turn. + */ export function buildAgentInvocationParentResultInboundMessage( invocation: AgentInvocation, nowMs = Date.now(), ): InboundMessage { if (!isTerminalAgentInvocation(invocation)) { - throw new Error( + throw new PermanentAgentInvocationParentNotificationError( `Agent invocation ${invocation.invocationId} is not terminal`, ); } @@ -69,8 +85,8 @@ export function buildAgentInvocationParentResultInboundMessage( const inboundMessageId = getAgentInvocationParentResultMessageId( invocation.invocationId, ); - if (invocation.destination.platform === "slack") { - return createSlackAgentInvocationResultInboundMessage({ + try { + return createAgentInvocationResultInboundMessage({ createdAtMs, destination: invocation.destination, inboundMessageId, @@ -79,25 +95,13 @@ export function buildAgentInvocationParentResultInboundMessage( receivedAtMs: nowMs, text, }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Agent invocation parent delivery could not build mailbox input"; + throw new PermanentAgentInvocationParentNotificationError(message); } - return { - conversationId: invocation.parentConversationId, - createdAtMs, - delivery: "defer", - destination: invocation.destination, - inboundMessageId, - input: { - authorId: invocation.invocationId, - text, - metadata: { - agentInvocationId: invocation.invocationId, - kind: "agent_invocation_result", - status: invocation.status, - }, - }, - receivedAtMs: nowMs, - source: "internal", - }; } /** @@ -105,7 +109,8 @@ export function buildAgentInvocationParentResultInboundMessage( * * Queue delivery is only a wake-up hint. The durable parent-notification status * remains pending until the mailbox append path succeeds so heartbeat repair - * can retry from invocation state. + * can retry from invocation state. Permanent builder/destination mismatches + * move to failed so they stay queryable without infinite retries. */ export async function notifyParentOfAgentInvocationResult( invocation: AgentInvocation, @@ -130,12 +135,26 @@ export async function notifyParentOfAgentInvocationResult( ) { return; } - await appendAndEnqueueInboundMessage({ - message: buildAgentInvocationParentResultInboundMessage(latest, nowMs), - conversationStore: options.conversationStore, - nowMs, - queue: options.queue, - state: options.state, - }); - await markAgentInvocationParentNotified(latest.invocationId, nowMs); + try { + await appendAndEnqueueInboundMessage({ + message: buildAgentInvocationParentResultInboundMessage(latest, nowMs), + conversationStore: options.conversationStore, + nowMs, + queue: options.queue, + state: options.state, + }); + await markAgentInvocationParentNotified(latest.invocationId, nowMs); + } catch (error) { + if (error instanceof PermanentAgentInvocationParentNotificationError) { + await markAgentInvocationParentNotificationFailed( + latest.invocationId, + nowMs, + ); + logException(error, "agent.invocation.parent_notification.failed", { + "app.agent.invocation_id": latest.invocationId, + }); + return; + } + throw error; + } } diff --git a/packages/junior/src/chat/agent-invocations/store.ts b/packages/junior/src/chat/agent-invocations/store.ts index 4663f6a652..59f37b5151 100644 --- a/packages/junior/src/chat/agent-invocations/store.ts +++ b/packages/junior/src/chat/agent-invocations/store.ts @@ -474,6 +474,26 @@ export async function markAgentInvocationParentNotified( ); } +/** Record a permanent parent-mailbox delivery failure that should not retry. */ +export async function markAgentInvocationParentNotificationFailed( + invocationId: string, + nowMs = Date.now(), +): Promise { + await getSqlExecutor() + .db() + .update(juniorAgentInvocations) + .set({ + parentNotificationStatus: "failed", + updatedAt: new Date(nowMs), + }) + .where( + and( + eq(juniorAgentInvocations.invocationId, invocationId), + eq(juniorAgentInvocations.parentNotificationStatus, "pending"), + ), + ); +} + /** Return whether an invocation already owns its immutable terminal result. */ export function isTerminalAgentInvocation( invocation: AgentInvocation, diff --git a/packages/junior/src/chat/resource-events/README.md b/packages/junior/src/chat/resource-events/README.md index 1a59fa1195..1186bb7dd4 100644 --- a/packages/junior/src/chat/resource-events/README.md +++ b/packages/junior/src/chat/resource-events/README.md @@ -36,9 +36,12 @@ conversation. - Normalized events contain a stable namespace and identifier plus a bounded, safe notification summary rather than a raw webhook payload. - Ingestion appends a system-authored conversation message and sends a normal - task-execution wake-up. Resource-event identity constants and detection live - in `actor.ts` (`RESOURCE_EVENT_SYSTEM_ACTOR`, synthetic Slack author id, and - message markers). Live and resume paths both execute as that system actor. + task-execution wake-up through the shared synthetic inbound builder in + `task-execution/synthetic-inbound.ts` (same durable mailbox shape agent- + invocation parent results use). Resource-event identity constants and + detection live in `actor.ts` (`RESOURCE_EVENT_SYSTEM_ACTOR`, synthetic Slack + author id, and message markers). Live and resume paths both execute as that + system actor. - A subscription selector is one Slack workspace, one namespace, one identifier, and one or more event types. `resourceType` and `label` are presentation metadata, not match keys. diff --git a/packages/junior/src/chat/resource-events/notification.ts b/packages/junior/src/chat/resource-events/notification.ts index 75f2c192a8..e6b308d7f3 100644 --- a/packages/junior/src/chat/resource-events/notification.ts +++ b/packages/junior/src/chat/resource-events/notification.ts @@ -1,6 +1,6 @@ import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { appendAndEnqueueInboundMessage } from "@/chat/task-execution/store"; -import { createSlackResourceEventInboundMessage } from "@/chat/task-execution/slack-work"; +import { createResourceEventInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import type { ResourceEventSubscription } from "@/chat/resource-events/store"; export interface ResourceEventNotification { @@ -66,7 +66,7 @@ export async function enqueueResourceEventNotification(args: { id: args.subscription.id, }; return await appendAndEnqueueInboundMessage({ - message: createSlackResourceEventInboundMessage({ + message: createResourceEventInboundMessage({ event: args.event, subscription, text: renderResourceEventNotificationText(args.subscription, args.event), diff --git a/packages/junior/src/chat/runtime/agent-continue-runner.ts b/packages/junior/src/chat/runtime/agent-continue-runner.ts index 8ba13ee049..3eb7bec6c0 100644 --- a/packages/junior/src/chat/runtime/agent-continue-runner.ts +++ b/packages/junior/src/chat/runtime/agent-continue-runner.ts @@ -77,8 +77,8 @@ import { RESOURCE_EVENT_SYSTEM_ACTOR, } from "@/chat/resource-events/actor"; import { - AGENT_INVOCATION_RESULT_SYSTEM_ACTOR, isAgentInvocationResultConversationMessage, + resolveAgentInvocationResultAuthority, } from "@/chat/agent-invocations/actor"; import type { AgentRunResult } from "@/chat/services/turn-result"; import type { AgentRunner } from "@/chat/runtime/agent-runner"; @@ -284,8 +284,9 @@ async function resolveSlackResumeUserActor(args: { * * Sources, in order: * 1. Caller routingContext (dispatch / OAuth already set actor + credentials) - * 2. Resource-event markers → system actor - * 3. Slack author + destination team + * 2. Agent-invocation result markers → parent authority from the invocation + * 3. Resource-event markers → system actor + * 4. Slack author + destination team * * Never reads Redis turn-session actor. Prefer setting actor first; credentials * come from the caller when already bound, else credentialContextForActor. @@ -296,6 +297,7 @@ async function resolveResumeExecutionIdentity(args: { teamId: string; userMessage: { author?: { userId?: string }; + id?: string; meta?: { eventType?: string }; }; }): Promise<{ actor: Actor; credentialContext: CredentialContext } | undefined> { @@ -314,13 +316,19 @@ async function resolveResumeExecutionIdentity(args: { : undefined; } + if (isAgentInvocationResultConversationMessage(args.userMessage)) { + const restored = await resolveAgentInvocationResultAuthority({ + messageId: args.userMessage.id, + }); + if (restored) { + return restored; + } + } + let actor: Actor | undefined = routing?.actor; if (!actor && isResourceEventConversationMessage(args.userMessage)) { actor = RESOURCE_EVENT_SYSTEM_ACTOR; } - if (!actor && isAgentInvocationResultConversationMessage(args.userMessage)) { - actor = AGENT_INVOCATION_RESULT_SYSTEM_ACTOR; - } if (!actor && args.userMessage.author?.userId) { actor = await resolveSlackResumeUserActor({ conversationId: args.conversationId, diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 83e579416e..c06ac21a7f 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -110,8 +110,8 @@ import { RESOURCE_EVENT_SYSTEM_ACTOR, } from "@/chat/resource-events/actor"; import { - AGENT_INVOCATION_RESULT_SYSTEM_ACTOR, isAgentInvocationResultSlackMessage, + resolveAgentInvocationResultAuthority, } from "@/chat/agent-invocations/actor"; import type { AgentContinueRequest } from "@/chat/services/agent-continue"; import { @@ -569,8 +569,17 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { executionActor = RESOURCE_EVENT_SYSTEM_ACTOR; credentialContext = credentialContextForActor(executionActor); } else if (isAgentInvocationResultSlackMessage(message)) { - executionActor = AGENT_INVOCATION_RESULT_SYSTEM_ACTOR; - credentialContext = credentialContextForActor(executionActor); + const restored = await resolveAgentInvocationResultAuthority({ + messageId: message.id, + raw: message.raw, + }); + if (!restored) { + throw new Error( + "Agent invocation result turn requires durable parent authority", + ); + } + executionActor = restored.actor; + credentialContext = restored.credentialContext; } else { executionActor = await ensureSlackMessageActorIdentity( message, @@ -1414,9 +1423,11 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { channelConfiguration: preparedState.channelConfiguration, disabledFeatures: options.execution?.disabledFeatures ?? - (message.author.isBot === true - ? (["interactive-auth"] as const) - : undefined), + (isAgentInvocationResultSlackMessage(message) + ? undefined + : message.author.isBot === true + ? (["interactive-auth"] as const) + : undefined), turnDeadlineAtMs: getTurnRequestDeadline()?.deadlineAtMs, }, state: { diff --git a/packages/junior/src/chat/task-execution/slack-work.ts b/packages/junior/src/chat/task-execution/slack-work.ts index 0ed0074761..f51d5fba3f 100644 --- a/packages/junior/src/chat/task-execution/slack-work.ts +++ b/packages/junior/src/chat/task-execution/slack-work.ts @@ -34,14 +34,12 @@ import { import { ensureSlackMessageActorIdentity } from "@/chat/services/message-actor-identity"; import { lookupSlackUser } from "@/chat/slack/user"; import { parseActorUserId, type SlackActorProfile } from "@/chat/actor"; -import { - isResourceEventSlackMessage, - RESOURCE_EVENT_SLACK_AUTHOR_ID, -} from "@/chat/resource-events/actor"; -import { - AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, - isAgentInvocationResultSlackMessage, -} from "@/chat/agent-invocations/actor"; +import { isResourceEventSlackMessage } from "@/chat/resource-events/actor"; +import { isAgentInvocationResultSlackMessage } from "@/chat/agent-invocations/actor"; +export { + createAgentInvocationResultInboundMessage as createSlackAgentInvocationResultInboundMessage, + createResourceEventInboundMessage as createSlackResourceEventInboundMessage, +} from "@/chat/task-execution/synthetic-inbound"; import { createSlackDestination, requireSlackDestination, @@ -444,26 +442,6 @@ interface SlackInboxTurnRuntime { ): Promise; } -interface SlackResourceEventInboundInput { - event: { - eventKey: string; - eventType: string; - occurredAtMs: number; - namespace: string; - identifier: string; - }; - subscription: { - conversationId: string; - destination: { - channelId: string; - platform: "slack"; - teamId: string; - }; - id: string; - }; - text: string; -} - export interface CreateSlackConversationWorkerOptions { crossActorMidRunMode?: CrossActorMidRunMode; getSlackAdapter: () => SlackAdapter; @@ -488,242 +466,6 @@ function requireSlackAuthorId(message: Message): string { return authorId; } -function parseSlackConversationId( - conversationId: string, -): { channelId: string; threadTs: string } | undefined { - const parts = conversationId.split(":"); - if (parts.length !== 3 || parts[0] !== "slack" || !parts[1] || !parts[2]) { - return undefined; - } - return { channelId: parts[1], threadTs: parts[2] }; -} - -function slackSerializedThread(input: { - channelId: string; - message: SerializedMessage; - threadTs: string; -}): z.output { - return { - _type: "chat:Thread", - adapterName: "slack", - channelId: input.channelId, - currentMessage: input.message, - id: `slack:${input.channelId}:${input.threadTs}`, - isDM: input.channelId.startsWith("D"), - }; -} - -/** - * Serialize a synthetic resource-event mailbox message without a native Slack - * message timestamp so Slack Web API calls cannot target the internal id. - */ -function slackSerializedResourceEventMessage(input: { - channelId: string; - eventType: string; - id: string; - text: string; - threadTs: string; - timestampIso: string; -}): SerializedMessage { - return { - _type: "chat:Message", - attachments: [], - author: { - userId: RESOURCE_EVENT_SLACK_AUTHOR_ID, - userName: "junior-event", - fullName: "Junior event", - isBot: true, - isMe: false, - }, - formatted: { type: "root", children: [] }, - id: input.id, - metadata: { - dateSent: input.timestampIso, - edited: false, - }, - raw: { - channel: input.channelId, - event_type: "resource_event", - resource_event_type: input.eventType, - thread_ts: input.threadTs, - type: "message", - user: RESOURCE_EVENT_SLACK_AUTHOR_ID, - }, - text: input.text, - threadId: `slack:${input.channelId}:${input.threadTs}`, - }; -} - -interface SlackAgentInvocationResultInboundInput { - createdAtMs: number; - destination: { - channelId: string; - platform: "slack"; - teamId: string; - }; - inboundMessageId: string; - invocationId: string; - parentConversationId: string; - receivedAtMs: number; - text: string; -} - -/** - * Serialize a synthetic agent-invocation result without a native Slack message - * timestamp so Slack Web API calls cannot target the internal id. - */ -function slackSerializedAgentInvocationResultMessage(input: { - channelId: string; - id: string; - text: string; - threadTs: string; - timestampIso: string; -}): SerializedMessage { - return { - _type: "chat:Message", - attachments: [], - author: { - userId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, - userName: "junior-agent", - fullName: "Junior agent", - isBot: true, - isMe: false, - }, - formatted: { type: "root", children: [] }, - id: input.id, - metadata: { - dateSent: input.timestampIso, - edited: false, - }, - raw: { - channel: input.channelId, - event_type: "agent_invocation_result", - thread_ts: input.threadTs, - type: "message", - user: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, - }, - text: input.text, - threadId: `slack:${input.channelId}:${input.threadTs}`, - }; -} - -/** Create a Slack mailbox record for one terminal agent-invocation result. */ -export function createSlackAgentInvocationResultInboundMessage( - input: SlackAgentInvocationResultInboundInput, -): InboundMessage { - const slack = parseSlackConversationId(input.parentConversationId); - if (!slack) { - throw new Error( - "Agent invocation result delivery currently requires a Slack conversation", - ); - } - if (input.destination.channelId !== slack.channelId) { - throw new Error( - "Agent invocation destination does not match Slack parent conversation", - ); - } - const timestampIso = new Date(input.createdAtMs).toISOString(); - const message = slackSerializedAgentInvocationResultMessage({ - channelId: slack.channelId, - id: input.inboundMessageId, - text: input.text, - threadTs: slack.threadTs, - timestampIso, - }); - const thread = slackSerializedThread({ - channelId: slack.channelId, - message, - threadTs: slack.threadTs, - }); - return { - conversationId: input.parentConversationId, - createdAtMs: input.createdAtMs, - destination: input.destination, - inboundMessageId: input.inboundMessageId, - delivery: "defer", - source: "internal", - receivedAtMs: input.receivedAtMs, - input: { - text: input.text, - authorId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, - metadata: { - agentInvocationId: input.invocationId, - installation: { - teamId: input.destination.teamId, - }, - kind: "agent_invocation_result", - platform: "slack", - route: "subscribed", - thread, - message, - } satisfies SlackConversationMessageMetadata, - }, - }; -} - -/** Create a Slack mailbox record for a subscribed resource-event notification. */ -export function createSlackResourceEventInboundMessage( - input: SlackResourceEventInboundInput, -): InboundMessage { - const slack = parseSlackConversationId(input.subscription.conversationId); - if (!slack) { - throw new Error( - "Resource event delivery currently requires a Slack conversation", - ); - } - const destination = input.subscription.destination; - if (destination.channelId !== slack.channelId) { - throw new Error( - "Resource event subscription destination does not match Slack conversation", - ); - } - const messageId = `resource-event-${input.subscription.id}-${input.event.eventKey}`; - const timestampIso = new Date(input.event.occurredAtMs).toISOString(); - const message = slackSerializedResourceEventMessage({ - channelId: slack.channelId, - eventType: input.event.eventType, - id: messageId, - text: input.text, - threadTs: slack.threadTs, - timestampIso, - }); - const thread = slackSerializedThread({ - channelId: slack.channelId, - message, - threadTs: slack.threadTs, - }); - return { - conversationId: input.subscription.conversationId, - createdAtMs: input.event.occurredAtMs, - destination, - inboundMessageId: `resource-event:${input.subscription.id}:${input.event.eventKey}`, - delivery: "defer", - source: "resource_event", - receivedAtMs: Date.now(), - input: { - text: input.text, - authorId: RESOURCE_EVENT_SLACK_AUTHOR_ID, - metadata: { - kind: "resource_event", - installation: { - teamId: destination.teamId, - }, - platform: "slack", - route: "subscribed", - thread, - message, - resourceEvent: { - eventKey: input.event.eventKey, - eventType: input.event.eventType, - namespace: input.event.namespace, - identifier: input.event.identifier, - subscriptionId: input.subscription.id, - }, - } satisfies SlackConversationMessageMetadata, - }, - }; -} - function getConnectedState(stateAdapter?: StateAdapter): StateAdapter { return stateAdapter ?? getStateAdapter(); } diff --git a/packages/junior/src/chat/task-execution/synthetic-inbound.ts b/packages/junior/src/chat/task-execution/synthetic-inbound.ts new file mode 100644 index 0000000000..d0cc076c52 --- /dev/null +++ b/packages/junior/src/chat/task-execution/synthetic-inbound.ts @@ -0,0 +1,284 @@ +/** + * Shared builders for synthetic internal mailbox wakes. + * + * Resource-event notifications and agent-invocation parent results both land as + * durable inbound messages with: + * - a stable inboundMessageId + * - kind + durable reference metadata + * - rendered text the parent turn consumes + * - a Slack envelope when the destination is Slack (ordinary Slack roots and + * destination-bearing parents such as agent-dispatch) + * + * Authority is not encoded here. Resource events run as the resource-event + * system principal; agent-invocation results restore actor + credentialContext + * from the durable invocation. + */ +import type { Destination } from "@sentry/junior-plugin-api"; +import type { SerializedMessage, SerializedThread } from "chat"; +import { AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID } from "@/chat/agent-invocations/actor"; +import { RESOURCE_EVENT_SLACK_AUTHOR_ID } from "@/chat/resource-events/actor"; +import type { InboundMessage } from "@/chat/task-execution/store"; + +function parseSlackConversationId( + conversationId: string, +): { channelId: string; threadTs: string } | undefined { + const parts = conversationId.split(":"); + if (parts.length !== 3 || parts[0] !== "slack" || !parts[1] || !parts[2]) { + return undefined; + } + return { channelId: parts[1], threadTs: parts[2] }; +} + +function slackSerializedThread(input: { + channelId: string; + message: SerializedMessage; + threadId: string; +}): SerializedThread { + return { + _type: "chat:Thread", + adapterName: "slack", + channelId: input.channelId, + currentMessage: input.message, + id: input.threadId, + isDM: input.channelId.startsWith("D"), + }; +} + +function slackSerializedSyntheticMessage(input: { + authorId: string; + authorUserName: string; + authorFullName: string; + channelId: string; + eventType: "agent_invocation_result" | "resource_event"; + extraRaw?: Record; + id: string; + text: string; + threadId: string; + threadTs?: string; + timestampIso: string; +}): SerializedMessage { + return { + _type: "chat:Message", + attachments: [], + author: { + userId: input.authorId, + userName: input.authorUserName, + fullName: input.authorFullName, + isBot: true, + isMe: false, + }, + formatted: { type: "root", children: [] }, + id: input.id, + metadata: { + dateSent: input.timestampIso, + edited: false, + }, + raw: { + channel: input.channelId, + event_type: input.eventType, + type: "message", + user: input.authorId, + ...(input.threadTs ? { thread_ts: input.threadTs } : {}), + ...(input.extraRaw ?? {}), + }, + text: input.text, + threadId: input.threadId, + }; +} + +function requireSlackDestination( + destination: Destination, + label: string, +): Extract { + if (destination.platform !== "slack") { + throw new Error(`${label} requires a Slack destination`); + } + return destination; +} + +/** + * Build parent-mailbox input for one terminal agent-invocation result. + * + * Parent conversation id is always the mailbox conversation identity. When the + * destination is Slack, attach the same synthetic envelope shape resource + * events use so the shared Slack worker can restore the turn — including for + * agent-dispatch parents whose conversation id is not `slack:channel:threadTs`. + * Local parents keep the simple kind + reference metadata wake. + */ +export function createAgentInvocationResultInboundMessage(input: { + createdAtMs: number; + destination: Destination; + inboundMessageId: string; + invocationId: string; + parentConversationId: string; + receivedAtMs: number; + text: string; +}): InboundMessage { + const base = { + conversationId: input.parentConversationId, + createdAtMs: input.createdAtMs, + delivery: "defer" as const, + destination: input.destination, + inboundMessageId: input.inboundMessageId, + receivedAtMs: input.receivedAtMs, + source: "internal" as const, + }; + const referenceMetadata = { + agentInvocationId: input.invocationId, + kind: "agent_invocation_result" as const, + }; + + if (input.destination.platform !== "slack") { + return { + ...base, + input: { + authorId: input.invocationId, + text: input.text, + metadata: referenceMetadata, + }, + }; + } + + const destination = requireSlackDestination( + input.destination, + "Agent invocation result delivery", + ); + const slackParent = parseSlackConversationId(input.parentConversationId); + if (slackParent && destination.channelId !== slackParent.channelId) { + throw new Error( + "Agent invocation destination does not match Slack parent conversation", + ); + } + if (!slackParent && !input.parentConversationId.includes(":")) { + throw new Error( + "Agent invocation parent conversation id is not a durable conversation identity", + ); + } + + const channelId = destination.channelId; + const threadTs = slackParent?.threadTs; + const timestampIso = new Date(input.createdAtMs).toISOString(); + const message = slackSerializedSyntheticMessage({ + authorId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + authorUserName: "junior-agent", + authorFullName: "Junior agent", + channelId, + eventType: "agent_invocation_result", + extraRaw: { + agent_invocation_id: input.invocationId, + }, + id: input.inboundMessageId, + text: input.text, + threadId: input.parentConversationId, + ...(threadTs ? { threadTs } : {}), + timestampIso, + }); + const thread = slackSerializedThread({ + channelId, + message, + threadId: input.parentConversationId, + }); + return { + ...base, + input: { + text: input.text, + authorId: AGENT_INVOCATION_RESULT_SLACK_AUTHOR_ID, + metadata: { + ...referenceMetadata, + installation: { + teamId: destination.teamId, + }, + platform: "slack", + route: "subscribed", + thread, + message, + }, + }, + }; +} + +/** Build mailbox input for one subscribed resource-event notification. */ +export function createResourceEventInboundMessage(input: { + event: { + eventKey: string; + eventType: string; + occurredAtMs: number; + namespace: string; + identifier: string; + }; + subscription: { + conversationId: string; + destination: Destination; + id: string; + }; + text: string; +}): InboundMessage { + const slackParent = parseSlackConversationId(input.subscription.conversationId); + if (!slackParent) { + throw new Error( + "Resource event delivery currently requires a Slack conversation", + ); + } + const destination = requireSlackDestination( + input.subscription.destination, + "Resource event delivery", + ); + if (destination.channelId !== slackParent.channelId) { + throw new Error( + "Resource event subscription destination does not match Slack conversation", + ); + } + + const messageId = `resource-event-${input.subscription.id}-${input.event.eventKey}`; + const timestampIso = new Date(input.event.occurredAtMs).toISOString(); + const message = slackSerializedSyntheticMessage({ + authorId: RESOURCE_EVENT_SLACK_AUTHOR_ID, + authorUserName: "junior-event", + authorFullName: "Junior event", + channelId: slackParent.channelId, + eventType: "resource_event", + extraRaw: { + resource_event_type: input.event.eventType, + }, + id: messageId, + text: input.text, + threadId: input.subscription.conversationId, + threadTs: slackParent.threadTs, + timestampIso, + }); + const thread = slackSerializedThread({ + channelId: slackParent.channelId, + message, + threadId: input.subscription.conversationId, + }); + return { + conversationId: input.subscription.conversationId, + createdAtMs: input.event.occurredAtMs, + destination, + inboundMessageId: `resource-event:${input.subscription.id}:${input.event.eventKey}`, + delivery: "defer", + source: "resource_event", + receivedAtMs: Date.now(), + input: { + text: input.text, + authorId: RESOURCE_EVENT_SLACK_AUTHOR_ID, + metadata: { + kind: "resource_event", + installation: { + teamId: destination.teamId, + }, + platform: "slack", + route: "subscribed", + thread, + message, + resourceEvent: { + eventKey: input.event.eventKey, + eventType: input.event.eventType, + namespace: input.event.namespace, + identifier: input.event.identifier, + subscriptionId: input.subscription.id, + }, + }, + }, + }; +} diff --git a/packages/junior/src/db/schema/agent-invocations.ts b/packages/junior/src/db/schema/agent-invocations.ts index f98bdf1f4e..33018637a7 100644 --- a/packages/junior/src/db/schema/agent-invocations.ts +++ b/packages/junior/src/db/schema/agent-invocations.ts @@ -31,6 +31,7 @@ export const AGENT_INVOCATION_MAILBOX_STATUSES = [ export const AGENT_INVOCATION_PARENT_NOTIFICATION_STATUSES = [ "pending", "notified", + "failed", ] as const; export const juniorAgentBindings = pgTable( diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index beea8e99ef..ce4f5b75fd 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createLocalSource } from "@sentry/junior-plugin-api"; +import { createLocalSource, createSlackSource } from "@sentry/junior-plugin-api"; import type { PiMessage } from "@/chat/pi/messages"; import { completeAgentInvocation, @@ -946,6 +946,103 @@ describe("agent invocation conversation work", () => { .sentRecords() .filter((record) => record.conversationId === parentConversationId), ).toHaveLength(1); + const parentResult = parentWork?.messages.find( + (message) => message.inboundMessageId === resultId, + ); + expect(parentResult?.input.metadata).toMatchObject({ + agentInvocationId: created.invocationId, + kind: "agent_invocation_result", + }); + } finally { + await fixture.close(); + } + }); + + it("delivers parent results to agent-dispatch parents without parsing conversation id as slack", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + const dispatchParentId = "agent-dispatch:dispatch_parent_result_1"; + const slackDestination = { + channelId: "C123", + platform: "slack" as const, + teamId: "T123", + }; + try { + await conversationStore.recordActivity({ + conversationId: dispatchParentId, + destination: slackDestination, + nowMs: 1_000, + source: "plugin", + }); + const created = await createAgentInvocation( + { + actor: { name: "dispatch-parent", platform: "system" }, + destination: slackDestination, + destinationVisibility: "public", + idempotencyKey: "dispatch-parent-1", + input: "Inspect from a dispatch parent.", + parentConversationId: dispatchParentId, + source: createSlackSource({ + channelId: slackDestination.channelId, + teamId: slackDestination.teamId, + visibility: "public", + }), + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Dispatch parent should see this.", + status: "completed", + }); + expect(terminal).toMatchObject({ + parentConversationId: dispatchParentId, + parentNotificationStatus: "pending", + }); + + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "notified", + }); + const parentWork = await getConversationWorkState({ + conversationId: dispatchParentId, + state, + }); + const resultId = getAgentInvocationParentResultMessageId( + created.invocationId, + ); + const parentResult = parentWork?.messages.find( + (message) => message.inboundMessageId === resultId, + ); + expect(parentResult).toMatchObject({ + conversationId: dispatchParentId, + destination: slackDestination, + source: "internal", + }); + expect(parentResult?.input.metadata).toMatchObject({ + agentInvocationId: created.invocationId, + kind: "agent_invocation_result", + platform: "slack", + route: "subscribed", + }); + expect( + (parentResult?.input.metadata as { message?: { raw?: unknown } }) + ?.message?.raw, + ).toMatchObject({ + agent_invocation_id: created.invocationId, + event_type: "agent_invocation_result", + }); } finally { await fixture.close(); } diff --git a/scripts/file-length-exceptions.mjs b/scripts/file-length-exceptions.mjs index 4700621038..ae61e3a9cd 100644 --- a/scripts/file-length-exceptions.mjs +++ b/scripts/file-length-exceptions.mjs @@ -36,8 +36,6 @@ export const fileLengthExceptions = { "Existing Slack runtime; split by runtime phase.", "packages/junior/src/chat/state/turn-session.ts": "Existing turn session persistence; split by persistence concern.", - "packages/junior/src/chat/task-execution/slack-work.ts": - "Existing Slack mailbox adapter; split synthetic ingress builders next.", "packages/junior/src/chat/task-execution/state.ts": "Existing mailbox and lease store; split without separating shared locks.", "packages/junior/src/cli/check.ts": From 2562248b3e50739edee6a7c1ecc86b18aa488288 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:02:31 +0000 Subject: [PATCH 4/5] test(agent): split parent-notification coverage under file limit Move parent-result delivery cases out of the oversized invocation work suite and cover permanent notification failure terminalization. Co-Authored-By: David Cramer --- ...ent-invocation-parent-notification.test.ts | 297 ++++++++++++++++++ .../integration/agent-invocation-work.test.ts | 164 +--------- 2 files changed, 299 insertions(+), 162 deletions(-) create mode 100644 packages/junior/tests/integration/agent-invocation-parent-notification.test.ts diff --git a/packages/junior/tests/integration/agent-invocation-parent-notification.test.ts b/packages/junior/tests/integration/agent-invocation-parent-notification.test.ts new file mode 100644 index 0000000000..9a846f2f21 --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-parent-notification.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource, createSlackSource } from "@sentry/junior-plugin-api"; +import { + completeAgentInvocation, + createAgentInvocation, + getAgentInvocation, + getAgentInvocationParentResultMessageId, +} from "@/chat/agent-invocations/store"; +import { + buildAgentInvocationParentResultInboundMessage, + notifyParentOfAgentInvocationResult, + PermanentAgentInvocationParentNotificationError, +} from "@/chat/agent-invocations/parent-notification"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { recoverPendingAgentInvocationParentNotifications } from "@/chat/agent-dispatch/heartbeat"; +import { getConversationWorkState } from "@/chat/task-execution/store"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; + +const parentConversationId = "local:test:parent-agent"; +const destination = { + conversationId: parentConversationId, + platform: "local", +} as const; +const invocationInput = { + actor: { name: "parent-agent", platform: "system" } as const, + destination, + destinationVisibility: "private" as const, + input: "Summarize the durable task.", + parentConversationId, + reasoningLevel: "medium" as const, + source: createLocalSource(parentConversationId), +}; + +async function prepareParentConversation() { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination, + nowMs: 1_000, + source: "local", + }); + return { conversationStore, fixture }; +} + +describe("agent invocation parent notification", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("repairs pending parent notification delivery once", async () => { + const { fixture } = await prepareParentConversation(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAgentInvocation( + { + ...invocationInput, + idempotencyKey: "parent-notify-repair-1", + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Parent should see this.", + status: "completed", + }); + expect(terminal).toMatchObject({ + parentNotificationStatus: "pending", + status: "completed", + }); + + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 5_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "notified", + }); + const parentWork = await getConversationWorkState({ + conversationId: parentConversationId, + state, + }); + const resultId = getAgentInvocationParentResultMessageId( + created.invocationId, + ); + expect( + parentWork?.messages.filter( + (message) => message.inboundMessageId === resultId, + ), + ).toHaveLength(1); + expect( + queue + .sentRecords() + .filter((record) => record.conversationId === parentConversationId), + ).toHaveLength(1); + const parentResult = parentWork?.messages.find( + (message) => message.inboundMessageId === resultId, + ); + expect(parentResult?.input.metadata).toMatchObject({ + agentInvocationId: created.invocationId, + kind: "agent_invocation_result", + }); + } finally { + await fixture.close(); + } + }); + + it("delivers parent results to agent-dispatch parents without parsing conversation id as slack", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + const dispatchParentId = "agent-dispatch:dispatch_parent_result_1"; + const slackDestination = { + channelId: "C123", + platform: "slack" as const, + teamId: "T123", + }; + try { + await conversationStore.recordActivity({ + conversationId: dispatchParentId, + destination: slackDestination, + nowMs: 1_000, + source: "plugin", + }); + const created = await createAgentInvocation( + { + actor: { name: "dispatch-parent", platform: "system" }, + destination: slackDestination, + destinationVisibility: "public", + idempotencyKey: "dispatch-parent-1", + input: "Inspect from a dispatch parent.", + parentConversationId: dispatchParentId, + source: createSlackSource({ + channelId: slackDestination.channelId, + teamId: slackDestination.teamId, + visibility: "public", + }), + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Dispatch parent should see this.", + status: "completed", + }); + expect(terminal).toMatchObject({ + parentConversationId: dispatchParentId, + parentNotificationStatus: "pending", + }); + + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "notified", + }); + const parentWork = await getConversationWorkState({ + conversationId: dispatchParentId, + state, + }); + const resultId = getAgentInvocationParentResultMessageId( + created.invocationId, + ); + const parentResult = parentWork?.messages.find( + (message) => message.inboundMessageId === resultId, + ); + expect(parentResult).toMatchObject({ + conversationId: dispatchParentId, + destination: slackDestination, + source: "internal", + }); + expect(parentResult?.input.metadata).toMatchObject({ + agentInvocationId: created.invocationId, + kind: "agent_invocation_result", + platform: "slack", + route: "subscribed", + }); + expect( + (parentResult?.input.metadata as { message?: { raw?: unknown } }) + ?.message?.raw, + ).toMatchObject({ + agent_invocation_id: created.invocationId, + event_type: "agent_invocation_result", + }); + } finally { + await fixture.close(); + } + }); + + + it("marks permanent parent notification failures without infinite retry", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + // Force a permanent builder mismatch: Slack destination channel differs + // from the parent conversation id channel. + const mismatchedSlackDestination = { + channelId: "C999", + platform: "slack" as const, + teamId: "T999", + }; + try { + await conversationStore.recordActivity({ + conversationId: "slack:C123:111.222", + destination: mismatchedSlackDestination, + nowMs: 1_000, + source: "slack", + }); + const created = await createAgentInvocation( + { + actor: { name: "parent-agent", platform: "system" }, + destination: mismatchedSlackDestination, + destinationVisibility: "public", + idempotencyKey: "parent-notify-fail-1", + input: "This delivery should fail permanently.", + // Parent id claims a different Slack channel than destination. + parentConversationId: "slack:C123:111.222", + source: createSlackSource({ + channelId: "C123", + teamId: "T999", + visibility: "public", + }), + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "unreachable parent result", + status: "completed", + }); + expect(terminal).toMatchObject({ + parentNotificationStatus: "pending", + status: "completed", + }); + + expect(() => + buildAgentInvocationParentResultInboundMessage(terminal!, 4_000), + ).toThrow(PermanentAgentInvocationParentNotificationError); + + await notifyParentOfAgentInvocationResult(terminal!, { + conversationStore, + nowMs: 4_000, + queue, + state, + }); + await notifyParentOfAgentInvocationResult( + (await getAgentInvocation(created.invocationId))!, + { + conversationStore, + nowMs: 5_000, + queue, + state, + }, + ); + + await expect(getAgentInvocation(created.invocationId)).resolves.toMatchObject({ + parentNotificationStatus: "failed", + }); + expect(queue.sentRecords()).toHaveLength(0); + const parentWork = await getConversationWorkState({ + conversationId: "slack:C123:111.222", + state, + }); + expect(parentWork?.messages ?? []).toHaveLength(0); + } finally { + await fixture.close(); + } + }); + +}); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index ce4f5b75fd..8bc7ea1974 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createLocalSource, createSlackSource } from "@sentry/junior-plugin-api"; +import { createLocalSource } from "@sentry/junior-plugin-api"; import type { PiMessage } from "@/chat/pi/messages"; import { completeAgentInvocation, @@ -23,10 +23,7 @@ import { createSqlStore } from "@/chat/conversations/sql/store"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; -import { - recoverPendingAgentInvocationMailboxAppends, - recoverPendingAgentInvocationParentNotifications, -} from "@/chat/agent-dispatch/heartbeat"; +import { recoverPendingAgentInvocationMailboxAppends } from "@/chat/agent-dispatch/heartbeat"; import { CONVERSATION_WORK_MAX_DELIVERY_ATTEMPTS, getConversationWorkState, @@ -891,161 +888,4 @@ describe("agent invocation conversation work", () => { } }); - it("repairs pending parent notification delivery once", async () => { - const { fixture } = await prepareParentConversation(); - const queue = createConversationWorkQueueTestAdapter(); - const state = getStateAdapter(); - await state.connect(); - try { - const created = await createAgentInvocation( - { - ...invocationInput, - idempotencyKey: "parent-notify-repair-1", - }, - 2_000, - ); - const terminal = await completeAgentInvocation({ - invocationId: created.invocationId, - nowMs: 3_000, - result: "Parent should see this.", - status: "completed", - }); - expect(terminal).toMatchObject({ - parentNotificationStatus: "pending", - status: "completed", - }); - - await recoverPendingAgentInvocationParentNotifications({ - conversationWorkQueue: queue, - nowMs: 4_000, - }); - await recoverPendingAgentInvocationParentNotifications({ - conversationWorkQueue: queue, - nowMs: 5_000, - }); - - await expect( - getAgentInvocation(created.invocationId), - ).resolves.toMatchObject({ - parentNotificationStatus: "notified", - }); - const parentWork = await getConversationWorkState({ - conversationId: parentConversationId, - state, - }); - const resultId = getAgentInvocationParentResultMessageId( - created.invocationId, - ); - expect( - parentWork?.messages.filter( - (message) => message.inboundMessageId === resultId, - ), - ).toHaveLength(1); - expect( - queue - .sentRecords() - .filter((record) => record.conversationId === parentConversationId), - ).toHaveLength(1); - const parentResult = parentWork?.messages.find( - (message) => message.inboundMessageId === resultId, - ); - expect(parentResult?.input.metadata).toMatchObject({ - agentInvocationId: created.invocationId, - kind: "agent_invocation_result", - }); - } finally { - await fixture.close(); - } - }); - - it("delivers parent results to agent-dispatch parents without parsing conversation id as slack", async () => { - const fixture = createConfiguredJuniorSqlFixture(); - await migrateSchema(fixture.sql); - const conversationStore = createSqlStore(fixture.sql); - const queue = createConversationWorkQueueTestAdapter(); - const state = getStateAdapter(); - await state.connect(); - const dispatchParentId = "agent-dispatch:dispatch_parent_result_1"; - const slackDestination = { - channelId: "C123", - platform: "slack" as const, - teamId: "T123", - }; - try { - await conversationStore.recordActivity({ - conversationId: dispatchParentId, - destination: slackDestination, - nowMs: 1_000, - source: "plugin", - }); - const created = await createAgentInvocation( - { - actor: { name: "dispatch-parent", platform: "system" }, - destination: slackDestination, - destinationVisibility: "public", - idempotencyKey: "dispatch-parent-1", - input: "Inspect from a dispatch parent.", - parentConversationId: dispatchParentId, - source: createSlackSource({ - channelId: slackDestination.channelId, - teamId: slackDestination.teamId, - visibility: "public", - }), - }, - 2_000, - ); - const terminal = await completeAgentInvocation({ - invocationId: created.invocationId, - nowMs: 3_000, - result: "Dispatch parent should see this.", - status: "completed", - }); - expect(terminal).toMatchObject({ - parentConversationId: dispatchParentId, - parentNotificationStatus: "pending", - }); - - await recoverPendingAgentInvocationParentNotifications({ - conversationWorkQueue: queue, - nowMs: 4_000, - }); - - await expect( - getAgentInvocation(created.invocationId), - ).resolves.toMatchObject({ - parentNotificationStatus: "notified", - }); - const parentWork = await getConversationWorkState({ - conversationId: dispatchParentId, - state, - }); - const resultId = getAgentInvocationParentResultMessageId( - created.invocationId, - ); - const parentResult = parentWork?.messages.find( - (message) => message.inboundMessageId === resultId, - ); - expect(parentResult).toMatchObject({ - conversationId: dispatchParentId, - destination: slackDestination, - source: "internal", - }); - expect(parentResult?.input.metadata).toMatchObject({ - agentInvocationId: created.invocationId, - kind: "agent_invocation_result", - platform: "slack", - route: "subscribed", - }); - expect( - (parentResult?.input.metadata as { message?: { raw?: unknown } }) - ?.message?.raw, - ).toMatchObject({ - agent_invocation_id: created.invocationId, - event_type: "agent_invocation_result", - }); - } finally { - await fixture.close(); - } - }); - }); From 0448e201238c1cffb54a74450594344dde2470fa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:09:48 +0000 Subject: [PATCH 5/5] ref(agent): prune parent-result delivery dead code and policy tests Collapse duplicated dispatch parent-result handling into the shared worker path, drop unused synthetic builders/re-exports, and move behavior-focused parent delivery coverage into dedicated integration scenarios. Co-Authored-By: David Cramer Co-Authored-By: David Cramer --- .../junior/src/chat/agent-dispatch/work.ts | 19 +- .../src/chat/agent-invocations/actor.ts | 32 +- .../agent-invocations/parent-notification.ts | 109 ++---- .../src/chat/task-execution/slack-work.ts | 5 +- .../chat/task-execution/synthetic-inbound.ts | 5 - .../integration/agent-dispatch-work.test.ts | 113 ++++++ .../agent-invocation-parent-delivery.test.ts | 330 ++++++++++++++++++ ...ent-invocation-parent-notification.test.ts | 297 ---------------- .../integration/agent-invocation-work.test.ts | 1 - 9 files changed, 506 insertions(+), 405 deletions(-) create mode 100644 packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts delete mode 100644 packages/junior/tests/integration/agent-invocation-parent-notification.test.ts diff --git a/packages/junior/src/chat/agent-dispatch/work.ts b/packages/junior/src/chat/agent-dispatch/work.ts index 2aba3c97a4..b9c1780fab 100644 --- a/packages/junior/src/chat/agent-dispatch/work.ts +++ b/packages/junior/src/chat/agent-dispatch/work.ts @@ -476,14 +476,16 @@ export function createAgentDispatchConversationWorker( acknowledged = true; }; // Child results wake the parent mailbox. The durable result lives on the - // invocation; this wake is queue ownership only. Ack it, then advance any - // still-open dispatch the same way an empty resume wake would. - if (onlyAgentInvocationResultMessages(context.attempt.messages)) { + // invocation; this wake is queue ownership only. Never start a pending + // dispatch from a child-result wake alone — only resume an already-started + // turn through the shared path below. + const parentResultOnly = onlyAgentInvocationResultMessages( + context.attempt.messages, + ); + if (parentResultOnly) { await acknowledge(); - if (isTerminalDispatchStatus(dispatch.status)) { - return { status: "completed" }; - } - } else if (isTerminalDispatchStatus(dispatch.status)) { + } + if (isTerminalDispatchStatus(dispatch.status)) { await acknowledge(); return { status: "completed" }; } @@ -497,6 +499,9 @@ export function createAgentDispatchConversationWorker( await acknowledge(); return { status: "completed" }; } + if (parentResultOnly && durableResult.hasResumableRun !== true) { + return { status: "completed" }; + } if (Date.now() - dispatch.createdAtMs > AGENT_DISPATCH_MAX_AGE_MS) { await markDispatchFailed( dispatch.id, diff --git a/packages/junior/src/chat/agent-invocations/actor.ts b/packages/junior/src/chat/agent-invocations/actor.ts index 4635c4e211..559b276efb 100644 --- a/packages/junior/src/chat/agent-invocations/actor.ts +++ b/packages/junior/src/chat/agent-invocations/actor.ts @@ -42,25 +42,25 @@ export function isAgentInvocationResultSlackMessage(message: { return raw?.event_type === "agent_invocation_result"; } -/** Read the durable agent-invocation id stamped on a synthetic result message. */ -export function getAgentInvocationIdFromSlackMessage(message: { +function invocationIdFromResultMessage(args: { + messageId?: string; raw?: unknown; }): string | undefined { const raw = - message.raw && typeof message.raw === "object" - ? (message.raw as Record) + args.raw && typeof args.raw === "object" + ? (args.raw as Record) : undefined; - return typeof raw?.agent_invocation_id === "string" && + if ( + typeof raw?.agent_invocation_id === "string" && raw.agent_invocation_id.trim().length > 0 - ? raw.agent_invocation_id - : undefined; -} - -/** Parse the durable parent-result message id back to an invocation id. */ -export function getAgentInvocationIdFromParentResultMessageId( - messageId: string | undefined, -): string | undefined { - if (!messageId?.startsWith("agent-invocation:") || !messageId.endsWith(":result")) { + ) { + return raw.agent_invocation_id; + } + const messageId = args.messageId; + if ( + !messageId?.startsWith("agent-invocation:") || + !messageId.endsWith(":result") + ) { return undefined; } const invocationId = messageId.slice( @@ -75,9 +75,7 @@ export async function resolveAgentInvocationResultAuthority(args: { messageId?: string; raw?: unknown; }): Promise<{ actor: Actor; credentialContext: CredentialContext } | undefined> { - const invocationId = - getAgentInvocationIdFromSlackMessage(args) ?? - getAgentInvocationIdFromParentResultMessageId(args.messageId); + const invocationId = invocationIdFromResultMessage(args); if (!invocationId) { return undefined; } diff --git a/packages/junior/src/chat/agent-invocations/parent-notification.ts b/packages/junior/src/chat/agent-invocations/parent-notification.ts index efd403b79e..cf8b3193a5 100644 --- a/packages/junior/src/chat/agent-invocations/parent-notification.ts +++ b/packages/junior/src/chat/agent-invocations/parent-notification.ts @@ -9,10 +9,7 @@ import { } from "@/chat/agent-invocations/store"; import type { AgentInvocation } from "@/chat/agent-invocations/types"; import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; -import { - appendAndEnqueueInboundMessage, - type InboundMessage, -} from "@/chat/task-execution/store"; +import { appendAndEnqueueInboundMessage } from "@/chat/task-execution/store"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { logException } from "@/chat/logging"; @@ -23,18 +20,8 @@ type NotifyOptions = { state?: StateAdapter; }; -/** Permanent parent-delivery failures that should not keep retrying. */ -export class PermanentAgentInvocationParentNotificationError extends Error { - constructor(message: string) { - super(message); - this.name = "PermanentAgentInvocationParentNotificationError"; - } -} - /** Render the parent-facing terminal result text for one agent invocation. */ -export function renderAgentInvocationParentResultText( - invocation: AgentInvocation, -): string { +function renderParentResultText(invocation: AgentInvocation): string { const name = invocation.agentName ? `named agent "${invocation.agentName}"` : "child agent"; @@ -63,47 +50,6 @@ export function renderAgentInvocationParentResultText( return lines.join("\n"); } -/** - * Build the stable parent mailbox entry for one terminal agent invocation. - * - * Shape matches other internal wakes (dispatch / child invocation): kind + - * durable reference id + rendered text. Slack parents also carry the synthetic - * envelope resource events use so the shared Slack worker can restore the turn. - */ -export function buildAgentInvocationParentResultInboundMessage( - invocation: AgentInvocation, - nowMs = Date.now(), -): InboundMessage { - if (!isTerminalAgentInvocation(invocation)) { - throw new PermanentAgentInvocationParentNotificationError( - `Agent invocation ${invocation.invocationId} is not terminal`, - ); - } - const text = renderAgentInvocationParentResultText(invocation); - const createdAtMs = - "terminalAtMs" in invocation ? invocation.terminalAtMs : nowMs; - const inboundMessageId = getAgentInvocationParentResultMessageId( - invocation.invocationId, - ); - try { - return createAgentInvocationResultInboundMessage({ - createdAtMs, - destination: invocation.destination, - inboundMessageId, - invocationId: invocation.invocationId, - parentConversationId: invocation.parentConversationId, - receivedAtMs: nowMs, - text, - }); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Agent invocation parent delivery could not build mailbox input"; - throw new PermanentAgentInvocationParentNotificationError(message); - } -} - /** * Deliver one terminal child result into the parent mailbox once. * @@ -135,26 +81,41 @@ export async function notifyParentOfAgentInvocationResult( ) { return; } + + // Builder failures are permanent destination/identity mismatches. Append and + // queue failures stay pending for heartbeat repair. + let message; try { - await appendAndEnqueueInboundMessage({ - message: buildAgentInvocationParentResultInboundMessage(latest, nowMs), - conversationStore: options.conversationStore, - nowMs, - queue: options.queue, - state: options.state, + const createdAtMs = + "terminalAtMs" in latest ? latest.terminalAtMs : nowMs; + message = createAgentInvocationResultInboundMessage({ + createdAtMs, + destination: latest.destination, + inboundMessageId: getAgentInvocationParentResultMessageId( + latest.invocationId, + ), + invocationId: latest.invocationId, + parentConversationId: latest.parentConversationId, + receivedAtMs: nowMs, + text: renderParentResultText(latest), }); - await markAgentInvocationParentNotified(latest.invocationId, nowMs); } catch (error) { - if (error instanceof PermanentAgentInvocationParentNotificationError) { - await markAgentInvocationParentNotificationFailed( - latest.invocationId, - nowMs, - ); - logException(error, "agent.invocation.parent_notification.failed", { - "app.agent.invocation_id": latest.invocationId, - }); - return; - } - throw error; + await markAgentInvocationParentNotificationFailed( + latest.invocationId, + nowMs, + ); + logException(error, "agent.invocation.parent_notification.failed", { + "app.agent.invocation_id": latest.invocationId, + }); + return; } + + await appendAndEnqueueInboundMessage({ + message, + conversationStore: options.conversationStore, + nowMs, + queue: options.queue, + state: options.state, + }); + await markAgentInvocationParentNotified(latest.invocationId, nowMs); } diff --git a/packages/junior/src/chat/task-execution/slack-work.ts b/packages/junior/src/chat/task-execution/slack-work.ts index f51d5fba3f..eae1905967 100644 --- a/packages/junior/src/chat/task-execution/slack-work.ts +++ b/packages/junior/src/chat/task-execution/slack-work.ts @@ -36,10 +36,7 @@ import { lookupSlackUser } from "@/chat/slack/user"; import { parseActorUserId, type SlackActorProfile } from "@/chat/actor"; import { isResourceEventSlackMessage } from "@/chat/resource-events/actor"; import { isAgentInvocationResultSlackMessage } from "@/chat/agent-invocations/actor"; -export { - createAgentInvocationResultInboundMessage as createSlackAgentInvocationResultInboundMessage, - createResourceEventInboundMessage as createSlackResourceEventInboundMessage, -} from "@/chat/task-execution/synthetic-inbound"; +export { createResourceEventInboundMessage as createSlackResourceEventInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import { createSlackDestination, requireSlackDestination, diff --git a/packages/junior/src/chat/task-execution/synthetic-inbound.ts b/packages/junior/src/chat/task-execution/synthetic-inbound.ts index d0cc076c52..2fb429e02e 100644 --- a/packages/junior/src/chat/task-execution/synthetic-inbound.ts +++ b/packages/junior/src/chat/task-execution/synthetic-inbound.ts @@ -149,11 +149,6 @@ export function createAgentInvocationResultInboundMessage(input: { "Agent invocation destination does not match Slack parent conversation", ); } - if (!slackParent && !input.parentConversationId.includes(":")) { - throw new Error( - "Agent invocation parent conversation id is not a durable conversation identity", - ); - } const channelId = destination.channelId; const threadTs = slackParent?.threadTs; diff --git a/packages/junior/tests/integration/agent-dispatch-work.test.ts b/packages/junior/tests/integration/agent-dispatch-work.test.ts index d1b3350c59..e564ed4318 100644 --- a/packages/junior/tests/integration/agent-dispatch-work.test.ts +++ b/packages/junior/tests/integration/agent-dispatch-work.test.ts @@ -17,6 +17,7 @@ import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; +import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; import { listAgentTurnSessionSummariesForConversation, recordAgentTurnSessionSummary, @@ -426,4 +427,116 @@ describe("agent dispatch conversation work", () => { }); }); + it("keeps parent-result wakes on the dispatch worker without starting pending work", async () => { + const dispatch = await createDispatch("parent-result-pending"); + const conversationId = getDispatchConversationId(dispatch); + const resultMessage = createAgentInvocationResultInboundMessage({ + createdAtMs: 2_000, + destination, + inboundMessageId: "agent-invocation:inv-dispatch-pending:result", + invocationId: "inv-dispatch-pending", + parentConversationId: conversationId, + receivedAtMs: 2_000, + text: "[agent invocation result]\n\nA delegated child agent finished.", + }); + const ack = vi.fn(async () => {}); + const { context } = createContext(dispatch, { + attempt: { + ack, + conversationId, + destination, + drain: vi.fn(async () => []), + isFinalAttempt: false, + messages: [resultMessage], + }, + }); + const runTurn = vi.fn(async () => ({ outcome: "completed" as const })); + const resumeTurn = vi.fn(); + const slackWorker = vi.fn(async () => ({ status: "completed" as const })); + const route = createAgentDispatchWorkRouter({ + dispatchWorker: createAgentDispatchConversationWorker({ + resumeTurn, + runTurn, + }), + fallbackWorker: slackWorker, + }); + + await expect(route(context)).resolves.toEqual({ status: "completed" }); + expect(slackWorker).not.toHaveBeenCalled(); + expect(runTurn).not.toHaveBeenCalled(); + expect(resumeTurn).not.toHaveBeenCalled(); + expect(ack).toHaveBeenCalledOnce(); + await expect(getDispatchRecord(dispatch.id)).resolves.toMatchObject({ + status: "pending", + }); + }); + + it("resumes an already-started dispatch from a parent-result wake", async () => { + const dispatch = await createDispatch("parent-result-resume"); + const conversationId = getDispatchConversationId(dispatch); + const sessionId = getDispatchTurnId(dispatch.id); + await recordAgentTurnSessionSummary({ + actor: dispatch.actor, + conversationId, + destination: dispatch.destination, + destinationVisibility: dispatch.destinationVisibility, + dispatchId: dispatch.id, + sessionId, + sliceId: 1, + source: dispatch.source, + state: "awaiting_resume", + surface: "api", + }); + const resultMessage = createAgentInvocationResultInboundMessage({ + createdAtMs: 2_000, + destination, + inboundMessageId: "agent-invocation:inv-dispatch-resume:result", + invocationId: "inv-dispatch-resume", + parentConversationId: conversationId, + receivedAtMs: 2_000, + text: "[agent invocation result]\n\nA delegated child agent finished.", + }); + const ack = vi.fn(async () => {}); + const { context } = createContext(dispatch, { + attempt: { + ack, + conversationId, + destination, + drain: vi.fn(async () => []), + isFinalAttempt: false, + messages: [resultMessage], + }, + }); + const runTurn = vi.fn(); + const resumeTurn = vi.fn(async () => { + await recordAgentTurnSessionSummary({ + actor: dispatch.actor, + conversationId, + destination: dispatch.destination, + destinationVisibility: dispatch.destinationVisibility, + dispatchId: dispatch.id, + dispatchOutcome: "completed", + sessionId, + sliceId: 1, + source: dispatch.source, + state: "completed", + surface: "api", + }); + }); + const worker = createAgentDispatchConversationWorker({ + resumeTurn, + runTurn, + }); + + await expect(worker(context, dispatch.id)).resolves.toEqual({ + status: "completed", + }); + expect(runTurn).not.toHaveBeenCalled(); + expect(resumeTurn).toHaveBeenCalledOnce(); + expect(ack).toHaveBeenCalledOnce(); + await expect(getDispatchRecord(dispatch.id)).resolves.toMatchObject({ + status: "completed", + }); + }); + }); diff --git a/packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts b/packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts new file mode 100644 index 0000000000..dfb5f85946 --- /dev/null +++ b/packages/junior/tests/integration/agent-invocation-parent-delivery.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLocalSource, createSlackSource } from "@sentry/junior-plugin-api"; +import { + completeAgentInvocation, + createAgentInvocation, + getAgentInvocation, + getAgentInvocationParentResultMessageId, +} from "@/chat/agent-invocations/store"; +import { notifyParentOfAgentInvocationResult } from "@/chat/agent-invocations/parent-notification"; +import { recoverPendingAgentInvocationParentNotifications } from "@/chat/agent-dispatch/heartbeat"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; +import { getConversationWorkState } from "@/chat/task-execution/store"; +import { createAgentInvocationResultInboundMessage } from "@/chat/task-execution/synthetic-inbound"; +import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; +import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; +import { createTestChatRuntime } from "../fixtures/chat-runtime"; +import { + createTestDestination, + createTestMessage, + createTestThread, + TEST_SLACK_TEAM_ID, +} from "../fixtures/slack-harness"; +import { + deliverAssistantMessagesForTest, + flattenAgentRunRequestForTest, +} from "../fixtures/agent-runner"; +import { completedAgentRun } from "@/chat/runtime/agent-run-outcome"; + +const localParentConversationId = "local:test:parent-agent"; +const localDestination = { + conversationId: localParentConversationId, + platform: "local", +} as const; + +async function prepareLocalParent() { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + await conversationStore.recordActivity({ + conversationId: localParentConversationId, + destination: localDestination, + nowMs: 1_000, + source: "local", + }); + return { conversationStore, fixture }; +} + +describe("agent invocation parent delivery", () => { + afterEach(async () => { + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("repairs pending parent notification delivery once", async () => { + const { fixture } = await prepareLocalParent(); + const queue = createConversationWorkQueueTestAdapter(); + const state = getStateAdapter(); + await state.connect(); + try { + const created = await createAgentInvocation( + { + actor: { name: "parent-agent", platform: "system" }, + destination: localDestination, + destinationVisibility: "private", + idempotencyKey: "parent-notify-repair-1", + input: "Summarize the durable task.", + parentConversationId: localParentConversationId, + source: createLocalSource(localParentConversationId), + }, + 2_000, + ); + await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Parent should see this.", + status: "completed", + }); + + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 4_000, + }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 5_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "notified", + }); + const parentWork = await getConversationWorkState({ + conversationId: localParentConversationId, + state, + }); + const resultId = getAgentInvocationParentResultMessageId( + created.invocationId, + ); + expect( + parentWork?.messages.filter( + (message) => message.inboundMessageId === resultId, + ), + ).toHaveLength(1); + expect( + queue + .sentRecords() + .filter( + (record) => record.conversationId === localParentConversationId, + ), + ).toHaveLength(1); + } finally { + await fixture.close(); + } + }); + + it("marks permanent destination mismatches failed instead of retrying", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const queue = createConversationWorkQueueTestAdapter(); + const parentConversationId = "slack:C0PARENT:1700003000.001"; + try { + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination: { + channelId: "C0PARENT", + platform: "slack", + teamId: TEST_SLACK_TEAM_ID, + }, + nowMs: 1_000, + source: "slack", + }); + const created = await createAgentInvocation( + { + actor: { + platform: "slack", + teamId: TEST_SLACK_TEAM_ID, + userId: "U0PARENT", + }, + destination: { + channelId: "C0OTHER", + platform: "slack", + teamId: TEST_SLACK_TEAM_ID, + }, + destinationVisibility: "private", + idempotencyKey: "parent-notify-failed-1", + input: "Deliver with a mismatched destination.", + parentConversationId, + source: createSlackSource({ + channelId: "C0PARENT", + teamId: TEST_SLACK_TEAM_ID, + threadTs: "1700003000.001", + visibility: "private", + }), + }, + 2_000, + ); + const terminal = await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Should not land in the parent mailbox.", + status: "completed", + }); + + await notifyParentOfAgentInvocationResult(terminal!, { + queue, + nowMs: 4_000, + }); + await recoverPendingAgentInvocationParentNotifications({ + conversationWorkQueue: queue, + nowMs: 5_000, + }); + + await expect( + getAgentInvocation(created.invocationId), + ).resolves.toMatchObject({ + parentNotificationStatus: "failed", + }); + expect(queue.sentRecords()).toEqual([]); + } finally { + await fixture.close(); + } + }); + + it("runs parent-result turns with the invocation actor and credentials", async () => { + const fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + const conversationStore = createSqlStore(fixture.sql); + const parentConversationId = "slack:C0PARENT:1700003000.001"; + const slackDestination = { + channelId: "C0PARENT", + platform: "slack" as const, + teamId: TEST_SLACK_TEAM_ID, + }; + const parentActor = { + platform: "slack" as const, + teamId: TEST_SLACK_TEAM_ID, + userId: "U0PARENT", + userName: "parent-user", + fullName: "Parent User", + }; + const replyContexts: unknown[] = []; + try { + await conversationStore.recordActivity({ + conversationId: parentConversationId, + destination: slackDestination, + nowMs: 1_000, + source: "slack", + }); + const created = await createAgentInvocation( + { + actor: parentActor, + credentialContext: { + actor: { type: "user", userId: "U0PARENT" }, + }, + destination: slackDestination, + destinationVisibility: "private", + idempotencyKey: "parent-authority-1", + input: "Inspect the failing checks.", + parentConversationId, + source: createSlackSource({ + channelId: slackDestination.channelId, + teamId: slackDestination.teamId, + threadTs: "1700003000.001", + visibility: "private", + }), + }, + 2_000, + ); + await completeAgentInvocation({ + invocationId: created.invocationId, + nowMs: 3_000, + result: "Checks are green now.", + status: "completed", + }); + const inbound = createAgentInvocationResultInboundMessage({ + createdAtMs: 3_000, + destination: slackDestination, + inboundMessageId: getAgentInvocationParentResultMessageId( + created.invocationId, + ), + invocationId: created.invocationId, + parentConversationId, + receivedAtMs: 4_000, + text: "[agent invocation result]\n\nResult:\nChecks are green now.", + }); + const metadata = inbound.input.metadata as { + message: { raw?: Record }; + }; + + const { slackRuntime } = createTestChatRuntime({ + services: { + subscribedReplyPolicy: { + completeObject: async () => { + throw new Error( + "agent invocation results bypass subscribed classifier", + ); + }, + }, + replyExecutor: { + agentRunner: { + run: async (request) => { + replyContexts.push(flattenAgentRunRequestForTest(request)); + const piMessages = await deliverAssistantMessagesForTest( + request, + [{ text: "I used the child result." }], + ); + return completedAgentRun({ + text: "I used the child result.", + piMessages, + diagnostics: { + assistantMessageCount: 1, + modelId: "test-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }); + }, + }, + }, + }, + }); + + const thread = createTestThread({ id: parentConversationId }); + const message = createTestMessage({ + id: inbound.inboundMessageId, + text: inbound.input.text, + isMention: false, + threadId: thread.id, + author: { + userId: "UJRNAGENT", + userName: "junior-agent", + fullName: "Junior agent", + isBot: true, + }, + raw: metadata.message.raw, + }); + + await slackRuntime.handleSubscribedMessage(thread, message, { + destination: createTestDestination(thread), + }); + + expect(replyContexts).toEqual([ + expect.objectContaining({ + conversationId: parentConversationId, + actor: parentActor, + credentialContext: { + actor: { type: "user", userId: "U0PARENT" }, + }, + }), + ]); + expect( + (replyContexts[0] as { policy?: { disabledFeatures?: unknown } }) + ?.policy?.disabledFeatures, + ).toBeUndefined(); + expect(thread.posts).toHaveLength(1); + expect(JSON.stringify(thread.posts[0])).toContain( + "I used the child result.", + ); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/junior/tests/integration/agent-invocation-parent-notification.test.ts b/packages/junior/tests/integration/agent-invocation-parent-notification.test.ts deleted file mode 100644 index 9a846f2f21..0000000000 --- a/packages/junior/tests/integration/agent-invocation-parent-notification.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createLocalSource, createSlackSource } from "@sentry/junior-plugin-api"; -import { - completeAgentInvocation, - createAgentInvocation, - getAgentInvocation, - getAgentInvocationParentResultMessageId, -} from "@/chat/agent-invocations/store"; -import { - buildAgentInvocationParentResultInboundMessage, - notifyParentOfAgentInvocationResult, - PermanentAgentInvocationParentNotificationError, -} from "@/chat/agent-invocations/parent-notification"; -import { migrateSchema } from "@/chat/conversations/sql/migrations"; -import { createSqlStore } from "@/chat/conversations/sql/store"; -import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; -import { recoverPendingAgentInvocationParentNotifications } from "@/chat/agent-dispatch/heartbeat"; -import { getConversationWorkState } from "@/chat/task-execution/store"; -import { createConversationWorkQueueTestAdapter } from "../fixtures/conversation-work"; -import { createConfiguredJuniorSqlFixture } from "../fixtures/sql"; - -const parentConversationId = "local:test:parent-agent"; -const destination = { - conversationId: parentConversationId, - platform: "local", -} as const; -const invocationInput = { - actor: { name: "parent-agent", platform: "system" } as const, - destination, - destinationVisibility: "private" as const, - input: "Summarize the durable task.", - parentConversationId, - reasoningLevel: "medium" as const, - source: createLocalSource(parentConversationId), -}; - -async function prepareParentConversation() { - const fixture = createConfiguredJuniorSqlFixture(); - await migrateSchema(fixture.sql); - const conversationStore = createSqlStore(fixture.sql); - await conversationStore.recordActivity({ - conversationId: parentConversationId, - destination, - nowMs: 1_000, - source: "local", - }); - return { conversationStore, fixture }; -} - -describe("agent invocation parent notification", () => { - afterEach(async () => { - await disconnectStateAdapter(); - vi.restoreAllMocks(); - }); - - it("repairs pending parent notification delivery once", async () => { - const { fixture } = await prepareParentConversation(); - const queue = createConversationWorkQueueTestAdapter(); - const state = getStateAdapter(); - await state.connect(); - try { - const created = await createAgentInvocation( - { - ...invocationInput, - idempotencyKey: "parent-notify-repair-1", - }, - 2_000, - ); - const terminal = await completeAgentInvocation({ - invocationId: created.invocationId, - nowMs: 3_000, - result: "Parent should see this.", - status: "completed", - }); - expect(terminal).toMatchObject({ - parentNotificationStatus: "pending", - status: "completed", - }); - - await recoverPendingAgentInvocationParentNotifications({ - conversationWorkQueue: queue, - nowMs: 4_000, - }); - await recoverPendingAgentInvocationParentNotifications({ - conversationWorkQueue: queue, - nowMs: 5_000, - }); - - await expect( - getAgentInvocation(created.invocationId), - ).resolves.toMatchObject({ - parentNotificationStatus: "notified", - }); - const parentWork = await getConversationWorkState({ - conversationId: parentConversationId, - state, - }); - const resultId = getAgentInvocationParentResultMessageId( - created.invocationId, - ); - expect( - parentWork?.messages.filter( - (message) => message.inboundMessageId === resultId, - ), - ).toHaveLength(1); - expect( - queue - .sentRecords() - .filter((record) => record.conversationId === parentConversationId), - ).toHaveLength(1); - const parentResult = parentWork?.messages.find( - (message) => message.inboundMessageId === resultId, - ); - expect(parentResult?.input.metadata).toMatchObject({ - agentInvocationId: created.invocationId, - kind: "agent_invocation_result", - }); - } finally { - await fixture.close(); - } - }); - - it("delivers parent results to agent-dispatch parents without parsing conversation id as slack", async () => { - const fixture = createConfiguredJuniorSqlFixture(); - await migrateSchema(fixture.sql); - const conversationStore = createSqlStore(fixture.sql); - const queue = createConversationWorkQueueTestAdapter(); - const state = getStateAdapter(); - await state.connect(); - const dispatchParentId = "agent-dispatch:dispatch_parent_result_1"; - const slackDestination = { - channelId: "C123", - platform: "slack" as const, - teamId: "T123", - }; - try { - await conversationStore.recordActivity({ - conversationId: dispatchParentId, - destination: slackDestination, - nowMs: 1_000, - source: "plugin", - }); - const created = await createAgentInvocation( - { - actor: { name: "dispatch-parent", platform: "system" }, - destination: slackDestination, - destinationVisibility: "public", - idempotencyKey: "dispatch-parent-1", - input: "Inspect from a dispatch parent.", - parentConversationId: dispatchParentId, - source: createSlackSource({ - channelId: slackDestination.channelId, - teamId: slackDestination.teamId, - visibility: "public", - }), - }, - 2_000, - ); - const terminal = await completeAgentInvocation({ - invocationId: created.invocationId, - nowMs: 3_000, - result: "Dispatch parent should see this.", - status: "completed", - }); - expect(terminal).toMatchObject({ - parentConversationId: dispatchParentId, - parentNotificationStatus: "pending", - }); - - await recoverPendingAgentInvocationParentNotifications({ - conversationWorkQueue: queue, - nowMs: 4_000, - }); - - await expect( - getAgentInvocation(created.invocationId), - ).resolves.toMatchObject({ - parentNotificationStatus: "notified", - }); - const parentWork = await getConversationWorkState({ - conversationId: dispatchParentId, - state, - }); - const resultId = getAgentInvocationParentResultMessageId( - created.invocationId, - ); - const parentResult = parentWork?.messages.find( - (message) => message.inboundMessageId === resultId, - ); - expect(parentResult).toMatchObject({ - conversationId: dispatchParentId, - destination: slackDestination, - source: "internal", - }); - expect(parentResult?.input.metadata).toMatchObject({ - agentInvocationId: created.invocationId, - kind: "agent_invocation_result", - platform: "slack", - route: "subscribed", - }); - expect( - (parentResult?.input.metadata as { message?: { raw?: unknown } }) - ?.message?.raw, - ).toMatchObject({ - agent_invocation_id: created.invocationId, - event_type: "agent_invocation_result", - }); - } finally { - await fixture.close(); - } - }); - - - it("marks permanent parent notification failures without infinite retry", async () => { - const fixture = createConfiguredJuniorSqlFixture(); - await migrateSchema(fixture.sql); - const conversationStore = createSqlStore(fixture.sql); - const queue = createConversationWorkQueueTestAdapter(); - const state = getStateAdapter(); - await state.connect(); - // Force a permanent builder mismatch: Slack destination channel differs - // from the parent conversation id channel. - const mismatchedSlackDestination = { - channelId: "C999", - platform: "slack" as const, - teamId: "T999", - }; - try { - await conversationStore.recordActivity({ - conversationId: "slack:C123:111.222", - destination: mismatchedSlackDestination, - nowMs: 1_000, - source: "slack", - }); - const created = await createAgentInvocation( - { - actor: { name: "parent-agent", platform: "system" }, - destination: mismatchedSlackDestination, - destinationVisibility: "public", - idempotencyKey: "parent-notify-fail-1", - input: "This delivery should fail permanently.", - // Parent id claims a different Slack channel than destination. - parentConversationId: "slack:C123:111.222", - source: createSlackSource({ - channelId: "C123", - teamId: "T999", - visibility: "public", - }), - }, - 2_000, - ); - const terminal = await completeAgentInvocation({ - invocationId: created.invocationId, - nowMs: 3_000, - result: "unreachable parent result", - status: "completed", - }); - expect(terminal).toMatchObject({ - parentNotificationStatus: "pending", - status: "completed", - }); - - expect(() => - buildAgentInvocationParentResultInboundMessage(terminal!, 4_000), - ).toThrow(PermanentAgentInvocationParentNotificationError); - - await notifyParentOfAgentInvocationResult(terminal!, { - conversationStore, - nowMs: 4_000, - queue, - state, - }); - await notifyParentOfAgentInvocationResult( - (await getAgentInvocation(created.invocationId))!, - { - conversationStore, - nowMs: 5_000, - queue, - state, - }, - ); - - await expect(getAgentInvocation(created.invocationId)).resolves.toMatchObject({ - parentNotificationStatus: "failed", - }); - expect(queue.sentRecords()).toHaveLength(0); - const parentWork = await getConversationWorkState({ - conversationId: "slack:C123:111.222", - state, - }); - expect(parentWork?.messages ?? []).toHaveLength(0); - } finally { - await fixture.close(); - } - }); - -}); diff --git a/packages/junior/tests/integration/agent-invocation-work.test.ts b/packages/junior/tests/integration/agent-invocation-work.test.ts index 8bc7ea1974..e08c67022a 100644 --- a/packages/junior/tests/integration/agent-invocation-work.test.ts +++ b/packages/junior/tests/integration/agent-invocation-work.test.ts @@ -887,5 +887,4 @@ describe("agent invocation conversation work", () => { await fixture.close(); } }); - });