diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index 4f96f1731..872bb62c8 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -15,8 +15,8 @@ Canonical words used across Junior's code and documentation. - **Sandbox**: an isolated execution environment for a run or snapshot build. - **Conversation**: the durable container for visible history and execution state, identified by a globally unique `conversationId`. -- **Source**: where an inbound event came from, such as Slack, local CLI, web - (dashboard), scheduler, or plugin dispatch. +- **Source**: where an inbound event came from, such as Slack, the Junior + Conversation API, local CLI, scheduler, or plugin dispatch. - **Destination**: where Junior sends output or side effects. - **Location**: the optional provider container associated with a conversation, identified by Junior and provider ids. Conversation visibility is separate. diff --git a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts index 4ac4b08f1..f2cca6cdd 100644 --- a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts +++ b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts @@ -49,7 +49,7 @@ export function mailboxMessageFromOutbox( messageId: message.messageId, receivedAt: message.createdAt, role: "user", - source: "web", + source: "api", text: message.message, }; } @@ -72,7 +72,9 @@ export function mergeConversationMailboxMessages( const outboxMessages = outbox ?? []; let next: readonly ConversationMailboxMessage[] = serverMessages; if (outboxMessages.length > 0) { - const serverIds = new Set(serverMessages.map((message) => message.messageId)); + const serverIds = new Set( + serverMessages.map((message) => message.messageId), + ); const extras = outboxMessages .filter((message) => !serverIds.has(message.messageId)) .map(mailboxMessageFromOutbox); diff --git a/packages/junior-dashboard/src/client/pages/memory/memoryRecord.ts b/packages/junior-dashboard/src/client/pages/memory/memoryRecord.ts index 33eaa1209..c7f926a90 100644 --- a/packages/junior-dashboard/src/client/pages/memory/memoryRecord.ts +++ b/packages/junior-dashboard/src/client/pages/memory/memoryRecord.ts @@ -13,7 +13,7 @@ const memoryRecordSchema = z kind: z.enum(["preference", "procedure", "knowledge"]), observedAt: z.iso.datetime(), origin: z.enum(["automatic", "explicit", "other"]), - sourcePlatform: z.enum(["local", "slack", "web"]), + sourcePlatform: z.enum(["junior", "local", "slack", "web"]), visibility: z.enum(["private", "public"]), }) .strict(); diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 23937e0f4..e4e9f744e 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -134,7 +134,7 @@ export type TranscriptViewMessage = { failureReason?: ConversationTurnFailureReason; parts: TranscriptViewPart[]; role: "assistant" | "system" | "tool" | "user"; - source?: "slack" | "web"; + source?: "api" | "slack" | "web"; sourceSeq: number; timestamp?: number; }; diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index 1c47e3f12..6d97020b8 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -1524,7 +1524,7 @@ export function readMockConversationPendingMessages( messageId: `${conversationId}:pending-defer`, receivedAt: iso(nowMs, -3_500), role: "user" as const, - source: "web" as const, + source: "api" as const, text: "Keep the reply in Junior. I will paste the dashboard link next.", }, { @@ -1535,7 +1535,7 @@ export function readMockConversationPendingMessages( messageId: `${conversationId}:pending-third`, receivedAt: iso(nowMs, -2_500), role: "user" as const, - source: "web" as const, + source: "api" as const, text: "Third queued message.", }, { @@ -1546,7 +1546,7 @@ export function readMockConversationPendingMessages( messageId: `${conversationId}:pending-fourth`, receivedAt: iso(nowMs, -1_500), role: "user" as const, - source: "web" as const, + source: "api" as const, text: "Fourth queued message.", }, { @@ -1557,7 +1557,7 @@ export function readMockConversationPendingMessages( messageId: `${conversationId}:pending-fifth`, receivedAt: iso(nowMs, -500), role: "user" as const, - source: "web" as const, + source: "api" as const, text: "Fifth queued message.", }, ] @@ -1913,7 +1913,9 @@ export function readMockPeoplePluginReports( } /** Build mock person-scoped code activity for local profile QA. */ -export function readMockPeopleCode(email: string): CodePersonReport | undefined { +export function readMockPeopleCode( + email: string, +): CodePersonReport | undefined { const directory = readMockPeopleDirectory(); const person = directory.people.find( (entry) => entry.actor.email.toLowerCase() === email.trim().toLowerCase(), diff --git a/packages/junior-dashboard/tests/conversation-outbox.test.ts b/packages/junior-dashboard/tests/conversation-outbox.test.ts index 9189487bd..e7394c562 100644 --- a/packages/junior-dashboard/tests/conversation-outbox.test.ts +++ b/packages/junior-dashboard/tests/conversation-outbox.test.ts @@ -81,7 +81,7 @@ describe("conversation outbox", () => { messageId: "accepted-1", receivedAt: "2026-01-01T00:00:00.000Z", role: "user" as const, - source: "web" as const, + source: "api" as const, text: "queued", }, ]; @@ -103,7 +103,7 @@ describe("conversation outbox", () => { messageId: "accepted-1", receivedAt: "2026-01-01T00:00:00.000Z", role: "user" as const, - source: "web" as const, + source: "api" as const, text: "queued", }, ]; diff --git a/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx b/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx index 4433078b9..b8cb3bd1e 100644 --- a/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx +++ b/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx @@ -14,7 +14,7 @@ function message( messageId: "accepted-1", receivedAt: new Date(1_000).toISOString(), role: "user", - source: "web", + source: "api", text: "queued", ...overrides, }; diff --git a/packages/junior-dashboard/tests/pending-mailbox-transcript.test.ts b/packages/junior-dashboard/tests/pending-mailbox-transcript.test.ts index 2434ba10c..456373ff9 100644 --- a/packages/junior-dashboard/tests/pending-mailbox-transcript.test.ts +++ b/packages/junior-dashboard/tests/pending-mailbox-transcript.test.ts @@ -41,7 +41,7 @@ function pending( messageId: "msg-pending-1", receivedAt: new Date(2_000).toISOString(), role: "user", - source: "web", + source: "api", text: "still in the mailbox", ...overrides, }; @@ -73,7 +73,7 @@ describe("pending mailbox transcript merge", () => { expect(messages[1]).toMatchObject({ delivery: "defer", pending: true, - source: "web", + source: "api", parts: [{ type: "text", text: "still in the mailbox" }], }); }); diff --git a/packages/junior-memory/migrations/0011_junior_memory_source_platform.sql b/packages/junior-memory/migrations/0011_junior_memory_source_platform.sql new file mode 100644 index 000000000..a918ec1ce --- /dev/null +++ b/packages/junior-memory/migrations/0011_junior_memory_source_platform.sql @@ -0,0 +1,2 @@ +ALTER TABLE "junior_memory_memories" DROP CONSTRAINT "junior_memory_memories_source_platform_check";--> statement-breakpoint +ALTER TABLE "junior_memory_memories" ADD CONSTRAINT "junior_memory_memories_source_platform_check" CHECK ("junior_memory_memories"."source_platform" IN ('junior', 'slack', 'local', 'web')); diff --git a/packages/junior-memory/migrations/meta/0011_snapshot.json b/packages/junior-memory/migrations/meta/0011_snapshot.json new file mode 100644 index 000000000..e9157f336 --- /dev/null +++ b/packages/junior-memory/migrations/meta/0011_snapshot.json @@ -0,0 +1,411 @@ +{ + "id": "2a92905b-29d4-4efa-b5ea-0c1aed39cb72", + "prevId": "3094a78d-54ba-42dc-926d-7563a6a651bd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_memory_embeddings": { + "name": "junior_memory_embeddings", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dimensions": { + "name": "dimensions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_memory_embeddings_model_idx": { + "name": "junior_memory_embeddings_model_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dimensions", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_memory_embeddings_embedding_hnsw_idx": { + "name": "junior_memory_embeddings_embedding_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "junior_memory_embeddings_memory_id_junior_memory_memories_id_fk": { + "name": "junior_memory_embeddings_memory_id_junior_memory_memories_id_fk", + "tableFrom": "junior_memory_embeddings", + "tableTo": "junior_memory_memories", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_memory_embeddings_metric_check": { + "name": "junior_memory_embeddings_metric_check", + "value": "\"junior_memory_embeddings\".\"metric\" IN ('cosine')" + }, + "junior_memory_embeddings_dimensions_check": { + "name": "junior_memory_embeddings_dimensions_check", + "value": "\"junior_memory_embeddings\".\"dimensions\" = 1536" + } + }, + "isRLSEnabled": false + }, + "public.junior_memory_memories": { + "name": "junior_memory_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_key": { + "name": "subject_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"content\")", + "type": "stored" + } + }, + "source_platform": { + "name": "source_platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at_ms": { + "name": "observed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at_ms": { + "name": "expires_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "superseded_at_ms": { + "name": "superseded_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at_ms": { + "name": "archived_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_memory_memories_visible_idx": { + "name": "junior_memory_memories_visible_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_memory_memories\".\"archived_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_by_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_memory_memories_expiration_idx": { + "name": "junior_memory_memories_expiration_idx", + "columns": [ + { + "expression": "expires_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_memory_memories\".\"archived_at_ms\" IS NULL AND \"junior_memory_memories\".\"expires_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_memory_memories_search_idx": { + "name": "junior_memory_memories_search_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_memory_memories\".\"archived_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_by_id\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_memory_memories_idempotency_idx": { + "name": "junior_memory_memories_idempotency_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_memory_memories\".\"idempotency_key\" IS NOT NULL AND \"junior_memory_memories\".\"archived_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_by_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_memory_memories_scope_check": { + "name": "junior_memory_memories_scope_check", + "value": "\"junior_memory_memories\".\"scope\" IN ('private', 'public')" + }, + "junior_memory_memories_kind_check": { + "name": "junior_memory_memories_kind_check", + "value": "\"junior_memory_memories\".\"type\" IN (\n 'preference',\n 'procedure',\n 'knowledge'\n )" + }, + "junior_memory_memories_subject_type_check": { + "name": "junior_memory_memories_subject_type_check", + "value": "\"junior_memory_memories\".\"subject_type\" IN ('user', 'conversation', 'general')" + }, + "junior_memory_memories_subject_key_check": { + "name": "junior_memory_memories_subject_key_check", + "value": "(\"junior_memory_memories\".\"subject_type\" = 'general' AND \"junior_memory_memories\".\"subject_key\" IS NULL) OR (\"junior_memory_memories\".\"subject_type\" IN ('user', 'conversation') AND \"junior_memory_memories\".\"subject_key\" IS NOT NULL AND length(\"junior_memory_memories\".\"subject_key\") > 0)" + }, + "junior_memory_memories_source_platform_check": { + "name": "junior_memory_memories_source_platform_check", + "value": "\"junior_memory_memories\".\"source_platform\" IN ('junior', 'slack', 'local', 'web')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior-memory/migrations/meta/_journal.json b/packages/junior-memory/migrations/meta/_journal.json index 0af7a952d..69577d1e9 100644 --- a/packages/junior-memory/migrations/meta/_journal.json +++ b/packages/junior-memory/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1787458882949, "tag": "0010_memories_captured_scope_labels", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1787718233330, + "tag": "0011_junior_memory_source_platform", + "breakpoints": true } ] } diff --git a/packages/junior-memory/src/agent.ts b/packages/junior-memory/src/agent.ts index 3421a64b7..738c4eea3 100644 --- a/packages/junior-memory/src/agent.ts +++ b/packages/junior-memory/src/agent.ts @@ -286,6 +286,8 @@ function actorLabel(actor: z.output | undefined): string { return `system:${actor.name}`; case "slack": return `slack:${actor.teamId}:${actor.userId}`; + case "junior": + return `junior:${actor.userId}`; case "local": return `local:${actor.userId}`; case "web": @@ -297,6 +299,7 @@ function sourceLabel(source: z.output): string { switch (source.platform) { case "slack": return `slack:${source.teamId}:${source.channelId}`; + case "junior": case "web": case "local": return `${source.platform}:${source.conversationId}`; @@ -562,7 +565,9 @@ export function createMemoryAgent(model: PluginModel): MemoryAgent { relevantIds: [...new Set(decision.relevantIds)].filter((id) => candidateIds.has(id), ), - ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined), + ...(result.costUsd !== undefined + ? { costUsd: result.costUsd } + : undefined), }; }, async adjudicateSupersession(rawRequest) { @@ -587,7 +592,9 @@ export function createMemoryAgent(model: PluginModel): MemoryAgent { memories: extractedMemoriesFromResponse( extractMemoriesResponseSchema.parse(result.object), ), - ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined), + ...(result.costUsd !== undefined + ? { costUsd: result.costUsd } + : undefined), }; }, async reviewCreateRequest(rawRequest) { diff --git a/packages/junior-memory/src/db/schema.ts b/packages/junior-memory/src/db/schema.ts index df9f77d40..12da2470d 100644 --- a/packages/junior-memory/src/db/schema.ts +++ b/packages/junior-memory/src/db/schema.ts @@ -102,7 +102,7 @@ export const juniorMemoryMemories = pgTable( ), check( "junior_memory_memories_source_platform_check", - sql`${table.sourcePlatform} IN ('slack', 'local', 'web')`, + sql`${table.sourcePlatform} IN ('junior', 'slack', 'local', 'web')`, ), ], ); diff --git a/packages/junior-memory/src/types.ts b/packages/junior-memory/src/types.ts index 17abea8da..4c56418ca 100644 --- a/packages/junior-memory/src/types.ts +++ b/packages/junior-memory/src/types.ts @@ -10,7 +10,12 @@ export const MEMORY_SUBJECT_TYPES = [ "general", ] as const; // Durable attribution follows Source platform, including dashboard/web roots. -export const MEMORY_SOURCE_PLATFORMS = ["slack", "local", "web"] as const; +export const MEMORY_SOURCE_PLATFORMS = [ + "junior", + "slack", + "local", + "web", +] as const; export const MEMORY_EMBEDDING_METRICS = ["cosine"] as const; export const MEMORY_EMBEDDING_DIMENSIONS = 1536; diff --git a/packages/junior-plugin-api/src/context.ts b/packages/junior-plugin-api/src/context.ts index 3da7becfb..e9d765b7c 100644 --- a/packages/junior-plugin-api/src/context.ts +++ b/packages/junior-plugin-api/src/context.ts @@ -3,6 +3,8 @@ import type { ZodTypeAny } from "zod"; import { destinationSchema, identitySchema, + juniorActorSchema, + juniorDestinationSchema, webActorSchema, localActorSchema, platformSchema, @@ -17,6 +19,7 @@ import { export type Platform = z.output; export type Actor = z.output; export type SlackActor = z.output; +export type JuniorActor = z.output; export type LocalActor = z.output; export type WebActor = z.output; export type SystemActor = z.output; @@ -26,6 +29,7 @@ export type Source = z.output; export type SlackSource = Extract; export type LocalSource = Extract; export type WebSource = Extract; +export type JuniorSource = Extract; export type SourceVisibility = Source["visibility"]; export type Destination = z.output; @@ -33,6 +37,7 @@ export type Destination = z.output; export type SlackDestination = Extract; export type LocalDestination = Extract; +export type JuniorDestination = z.output; export interface PluginMetadata { name: string; @@ -111,7 +116,16 @@ export interface WebInvocationContext extends BaseInvocationContext { source: WebSource; } +export interface JuniorInvocationContext extends BaseInvocationContext { + /** Conversation that receives assistant output. */ + destination: JuniorDestination; + actor?: JuniorActor; + /** Source for this Conversation API call. */ + source: JuniorSource; +} + export type InvocationContext = + | JuniorInvocationContext | LocalInvocationContext | SlackInvocationContext | WebInvocationContext; @@ -144,6 +158,18 @@ export function createLocalSource(conversationId: string): LocalSource { }; } +/** Build a Conversation API source from a Junior Conversation id. */ +export function createJuniorSource( + conversationId: string, + visibility: SourceVisibility = "public", +): JuniorSource { + return { + platform: "junior", + visibility, + conversationId, + }; +} + /** Build a normalized web/dashboard source from a conversation id. */ export function createWebSource( conversationId: string, @@ -164,6 +190,7 @@ export function isPrivateSource(source: Source): boolean { /** Return the stable source identity used for idempotency and attribution. */ export function getSourceKey(source: Source): string | undefined { switch (source.platform) { + case "junior": case "web": case "local": return source.conversationId; diff --git a/packages/junior-plugin-api/src/schemas.ts b/packages/junior-plugin-api/src/schemas.ts index e21fa84a4..af223f761 100644 --- a/packages/junior-plugin-api/src/schemas.ts +++ b/packages/junior-plugin-api/src/schemas.ts @@ -22,7 +22,7 @@ const exactNonBlankStringSchema = nonBlankStringSchema.refine( ); /** Runtime platform names supported by plugin public contracts. */ -export const platformSchema = z.enum(["slack", "local"]); +export const platformSchema = z.enum(["junior", "local", "slack"]); /** Runtime source visibility visible to plugins. */ export const sourceVisibilitySchema = z.enum(["public", "private"]); @@ -49,8 +49,17 @@ export const localDestinationSchema = z }) .strict(); +/** Junior Conversation that receives output. */ +export const juniorDestinationSchema = z + .object({ + platform: z.literal("junior"), + conversationId: exactNonBlankStringSchema, + }) + .strict(); + /** Runtime-owned provider-neutral address for routing future work or side effects. */ export const destinationSchema = z.discriminatedUnion("platform", [ + juniorDestinationSchema, slackDestinationSchema, localDestinationSchema, ]); @@ -73,7 +82,9 @@ export const localSourceSchema = z }) .strict(); -/** Runtime-owned dashboard/web coordinates for the inbound invocation. */ +// TODO(dcramer): After 2026-09-08, migrate durable web Source and Actor values, +// then remove the old schemas. New Conversation API work uses `junior`. +/** Old dashboard Source accepted while stored values still use it. */ export const webSourceSchema = z .object({ platform: z.literal("web"), @@ -83,8 +94,18 @@ export const webSourceSchema = z }) .strict(); +/** Source for a Conversation API call. */ +export const juniorSourceSchema = z + .object({ + platform: z.literal("junior"), + visibility: sourceVisibilitySchema, + conversationId: exactNonBlankStringSchema, + }) + .strict(); + /** Runtime-owned provider-neutral coordinates for the inbound invocation. */ export const sourceSchema = z.discriminatedUnion("platform", [ + juniorSourceSchema, slackSourceSchema, localSourceSchema, webSourceSchema, @@ -135,6 +156,7 @@ export const localActorSchema = z }) .strict(); +/** Old dashboard Actor accepted while stored values still use it. */ export const webActorSchema = z .object({ ...actorProfileSchema, @@ -142,6 +164,14 @@ export const webActorSchema = z }) .strict(); +/** Signed-in Junior identity acting through the Conversation API. */ +export const juniorActorSchema = z + .object({ + ...actorProfileSchema, + platform: z.literal("junior"), + }) + .strict(); + export const systemActorSchema = z .object({ platform: z.literal("system"), @@ -153,6 +183,7 @@ export const systemActorSchema = z // System actors should not use `platform: "system"`. /** Runtime-provided actor identity visible to plugin hooks. */ export const actorSchema = z.discriminatedUnion("platform", [ + juniorActorSchema, slackActorSchema, localActorSchema, webActorSchema, diff --git a/packages/junior-plugin-api/src/tools.ts b/packages/junior-plugin-api/src/tools.ts index 98cfb674f..c7a68a6fa 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -1,6 +1,7 @@ import type { Actor, Identity, + JuniorInvocationContext, LocalInvocationContext, PluginContext, PluginEmbedder, @@ -572,6 +573,11 @@ interface SlackToolRegistrationContext slack: SlackToolRegistrationHookContext; } +interface JuniorToolRegistrationContext + extends BaseToolRegistrationHookContext, JuniorInvocationContext { + slack?: never; +} + interface LocalToolRegistrationContext extends BaseToolRegistrationHookContext, LocalInvocationContext { slack?: never; @@ -583,6 +589,7 @@ interface WebToolRegistrationContext } export type ToolRegistrationHookContext = + | JuniorToolRegistrationContext | LocalToolRegistrationContext | SlackToolRegistrationContext | WebToolRegistrationContext; diff --git a/packages/junior/scripts/acp-local-server.ts b/packages/junior/scripts/acp-local-server.ts index a2d489a69..048ca18d6 100644 --- a/packages/junior/scripts/acp-local-server.ts +++ b/packages/junior/scripts/acp-local-server.ts @@ -12,7 +12,7 @@ import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { getSqlExecutor } from "@/chat/db"; import { closeApiTurnWorkFixture, - createConversationWorkWebHarness, + createConversationApiHarness, } from "../tests/fixtures/api-turn"; import { streamScript } from "../tests/fixtures/conversation-work"; @@ -31,7 +31,7 @@ function localPort(): number { } await migrateSchema(getSqlExecutor()); -const harness = await createConversationWorkWebHarness( +const harness = await createConversationApiHarness( streamScript(process.env.JUNIOR_ACP_LOCAL_REPLY?.trim() || DEFAULT_REPLY), ); // Use the loopback request origin, not deployed callback origins from env files. diff --git a/packages/junior/src/api/acp/conversations.ts b/packages/junior/src/api/acp/conversations.ts index 3e56c974e..0301657a3 100644 --- a/packages/junior/src/api/acp/conversations.ts +++ b/packages/junior/src/api/acp/conversations.ts @@ -3,11 +3,11 @@ import type { StateAdapter } from "chat"; import type { User } from "@sentry/junior-plugin-api"; import { readConversationAccessFromSql } from "@/api/conversations/access"; import { - apiTurnIdForMessage, appendAndEnqueueApiConversationMessage, recordApiConversationActivity, - webActorFromEmail, } from "@/chat/api-turns/work"; +import { createJuniorActor } from "@/chat/actor"; +import { apiTurnIdForMessage } from "@/chat/api-turns/ids"; import { getAuthPausedApiTurnId } from "@/chat/api-turns/routing"; import { stopApiConversationTurn } from "@/chat/api-turns/stop"; import type { ConversationEventStore } from "@/chat/conversations/history"; @@ -76,13 +76,6 @@ interface ConversationOptions { state: StateAdapter; } -function actorFromUser(user: User) { - return webActorFromEmail( - user.email, - user.displayName ? { fullName: user.displayName } : undefined, - ); -} - async function hasConversationAccess( conversationId: string, user: User, @@ -156,7 +149,7 @@ export function createAcpConversations( async create({ conversationId, user }) { await recordApiConversationActivity({ - actor: actorFromUser(user), + actor: createJuniorActor(user), conversationId, conversationStore: options.conversationStore, nowMs: Date.now(), @@ -181,7 +174,7 @@ export function createAcpConversations( } const admission = await appendAndEnqueueApiConversationMessage( { - actor: actorFromUser(user), + actor: createJuniorActor(user), conversationId, idempotencyKey, message: text, @@ -229,7 +222,8 @@ export function createAcpConversations( const data = event.data; return data.type === "message" && data.role === "assistant" && - data.messageId.startsWith(assistantPrefix) + (data.meta?.turnId === turnId || + data.messageId.startsWith(assistantPrefix)) ? [ { id: data.messageId, diff --git a/packages/junior/src/api/conversations/create.ts b/packages/junior/src/api/conversations/create.ts index 7dde1aafc..b4656ae12 100644 --- a/packages/junior/src/api/conversations/create.ts +++ b/packages/junior/src/api/conversations/create.ts @@ -1,10 +1,9 @@ import type { User } from "@sentry/junior-plugin-api"; -import type { WebActor } from "@/chat/actor"; import { - webActorFromEmail, appendAndEnqueueApiConversationMessage, createAndEnqueueApiConversation, } from "@/chat/api-turns/work"; +import { createJuniorActor } from "@/chat/actor"; import { getConversationStore, getDb } from "@/chat/db"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; import { throwApiError } from "../http"; @@ -15,15 +14,7 @@ import type { } from "../schema/conversation"; import { readConversationAccessFromSql } from "./access"; -function actorFromViewer(viewer: User): WebActor { - const normalized = viewer.email.trim().toLowerCase(); - return webActorFromEmail(normalized, { - ...(viewer.displayName ? { fullName: viewer.displayName } : undefined), - userName: normalized.split("@")[0] || normalized, - }); -} - -/** Create a dashboard root conversation and enqueue its first message. */ +/** Create a Conversation through the API and enqueue its first Message. */ export async function createConversationForViewer( viewer: User, body: CreateConversationBody, @@ -31,7 +22,7 @@ export async function createConversationForViewer( try { return await createAndEnqueueApiConversation( { - actor: actorFromViewer(viewer), + actor: createJuniorActor(viewer), idempotencyKey: body.idempotencyKey, message: body.message, ...(body.visibility ? { visibility: body.visibility } : undefined), @@ -46,7 +37,7 @@ export async function createConversationForViewer( } } -/** Append one dashboard message to an existing conversation. */ +/** Append one API Message to an existing Conversation. */ export async function appendConversationMessageForViewer( viewer: User, conversationId: string, @@ -59,10 +50,13 @@ export async function appendConversationMessageForViewer( throwApiError(404, "Conversation not found."); } const destinationPlatform = conversation.destination?.platform; + // TODO(dcramer): After 2026-09-08, migrate stored local:web Conversations + // to Junior destinations, then remove this legacy destination check. const acceptsApiMessages = + destinationPlatform === "junior" || + destinationPlatform === "slack" || (destinationPlatform === "local" && - conversationId.startsWith("local:web:")) || - destinationPlatform === "slack"; + conversationId.startsWith("local:web:")); if (!acceptsApiMessages) { throwApiError(409, "Conversation does not accept API messages."); } @@ -79,7 +73,7 @@ export async function appendConversationMessageForViewer( try { return await appendAndEnqueueApiConversationMessage( { - actor: actorFromViewer(viewer), + actor: createJuniorActor(viewer), conversationId, idempotencyKey: body.idempotencyKey, message: body.message, diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 95b7230e3..f3f5eef22 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -137,7 +137,9 @@ function reportMessageActorIdentity( const actorIdentity = { ...(author.data.fullName ? { fullName: author.data.fullName } : undefined), ...(author.data.userId ? { slackUserId: author.data.userId } : undefined), - ...(author.data.userName ? { slackUserName: author.data.userName } : undefined), + ...(author.data.userName + ? { slackUserName: author.data.userName } + : undefined), }; return Object.keys(actorIdentity).length > 0 ? actorIdentity : undefined; } @@ -268,7 +270,9 @@ function reportToolResult(args: { startedSeq: args.start.seq, } : undefined), - ...(args.canExposePayload && output !== undefined ? { output } : undefined), + ...(args.canExposePayload && output !== undefined + ? { output } + : undefined), }, ], }; @@ -318,7 +322,9 @@ function reportEventData(args: { type: "message", messageId: data.messageId, role: data.role, - ...(data.meta?.source === "web" || data.meta?.source === "slack" + ...(data.meta?.source === "api" || + data.meta?.source === "web" || + data.meta?.source === "slack" ? { source: data.meta.source } : undefined), ...(actorIdentity ? { actorIdentity } : undefined), diff --git a/packages/junior/src/api/conversations/pending-messages.ts b/packages/junior/src/api/conversations/pending-messages.ts index 7ea989b85..77d58705b 100644 --- a/packages/junior/src/api/conversations/pending-messages.ts +++ b/packages/junior/src/api/conversations/pending-messages.ts @@ -14,8 +14,9 @@ import { type ConversationPendingMessagesReport, } from "../schema/conversation"; import { readConversationAccessFromSql } from "./access"; -import { webActorFromEmail } from "@/chat/api-turns/work"; -import { getWebAuthorization } from "@/chat/api-turns/authorization"; +import { getApiAuthorization } from "@/chat/api-turns/authorization"; +import { legacyApiActorId } from "@/chat/api-turns/legacy-actor"; +import { createJuniorActor } from "@/chat/actor"; const apiTurnMailboxMetadataSchema = z .object({ @@ -60,7 +61,9 @@ function actorIdentityFromApiMetadata( ): ActorIdentity { return { email: metadata.authorEmail.trim().toLowerCase(), - ...(metadata.authorFullName ? { fullName: metadata.authorFullName } : undefined), + ...(metadata.authorFullName + ? { fullName: metadata.authorFullName } + : undefined), }; } @@ -89,7 +92,7 @@ async function projectPendingMessage( message: InboundMessage, canExposePayload: boolean, ): Promise { - if (message.source === "web") { + if (message.source === "api" || message.source === "web") { const metadata = apiTurnMailboxMetadataSchema.safeParse( message.input.metadata, ); @@ -103,7 +106,7 @@ async function projectPendingMessage( messageId: metadata.data.messageId, receivedAt: isoFromMs(message.receivedAtMs), role: "user", - source: "web", + source: "api", ...(canExposePayload ? { actorIdentity: actorIdentityFromApiMetadata(metadata.data), @@ -158,7 +161,7 @@ async function projectPendingMessage( /** * Read accepted mailbox messages that have not reached durable history yet. * - * Returns only human-facing web and Slack rows. Internal mailbox work stays + * Returns only human-facing API and Slack rows. Internal mailbox work stays * hidden from the transcript. */ export async function readConversationPendingMessages( @@ -177,12 +180,20 @@ export async function readConversationPendingMessages( ).get(conversationId); const canExposePayload = access?.canViewPrivateContent ?? false; const isParticipant = access?.isParticipant ?? false; - const actorId = options.viewer?.email - ? webActorFromEmail(options.viewer.email).userId + const actorId = options.viewer + ? createJuniorActor(options.viewer).userId : undefined; + // TODO(dcramer): After 2026-09-08, remove the old Actor lookup after every + // old authorization request can still exist. const authorization = isParticipant && actorId - ? await getWebAuthorization({ actorId, conversationId }) + ? ((await getApiAuthorization({ actorId, conversationId })) ?? + (options.viewer?.email + ? await getApiAuthorization({ + actorId: legacyApiActorId(options.viewer.email), + conversationId, + }) + : undefined)) : undefined; const work = await getConversation({ conversationId }); diff --git a/packages/junior/src/api/conversations/stats.query.ts b/packages/junior/src/api/conversations/stats.query.ts index 630a12274..fc261d578 100644 --- a/packages/junior/src/api/conversations/stats.query.ts +++ b/packages/junior/src/api/conversations/stats.query.ts @@ -60,7 +60,8 @@ function actorLabel(row: { function surfaceLabel(source: string | null): string { if (source === "scheduler") return "Scheduler"; - if (source === "api" || source === "web") return "Web"; + if (source === "api") return "API"; + if (source === "web") return "Web"; if (source === "internal" || source === "local") return "Internal"; return "Conversation"; } diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index da2a1d587..9aeb3da9f 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -125,7 +125,7 @@ export const conversationPendingMessageSchema = z messageId: z.string().min(1), receivedAt: z.string().datetime(), role: z.literal("user"), - source: z.enum(["slack", "web"]), + source: z.enum(["api", "slack"]), text: z.string().optional(), redacted: z.literal(true).optional(), }) @@ -145,7 +145,7 @@ export const conversationPendingMessageSchema = z } }); -/** Participant-only authorization prompt for a parked web turn. */ +/** Participant-only authorization prompt for a parked API Turn. */ export const conversationPendingAuthorizationSchema = z .object({ authorizationUrl: z.string().url(), @@ -278,7 +278,7 @@ const conversationReportMessageEventDataSchema = z type: z.literal("message"), messageId: z.string().min(1), role: z.enum(["assistant", "system", "user"]), - source: z.enum(["slack", "web"]).optional(), + source: z.enum(["api", "slack", "web"]).optional(), actorIdentity: actorIdentitySchema.optional(), eventType: z.string().min(1).optional(), explicitMention: z.boolean().optional(), diff --git a/packages/junior/src/chat/README.md b/packages/junior/src/chat/README.md index 1300d044e..f181839d0 100644 --- a/packages/junior/src/chat/README.md +++ b/packages/junior/src/chat/README.md @@ -25,11 +25,11 @@ file. 7. The completed run result supplies diagnostics and artifacts; successful delivery or intentional no-reply completion commits the durable turn outcome. -The local CLI uses `local/runner.ts` directly rather than pretending to be a -mailbox-backed provider. API-authored root turns and dashboard continues of -existing conversations use the shared mailbox and worker through `api-turns/` -with `publishExternally: false`. Continues keep the conversation destination -(including Slack) for location context and never copy replies to the provider. +The local CLI uses `local/runner.ts` directly. Conversation API Turns use the +shared mailbox and worker through `api-turns/` with `publishExternally: false`. +Their Actor uses the signed-in Junior identity. Their Source and Destination +name the Junior Conversation, even when it has a Slack Location. +Replies stay in the Conversation log and are not copied to Slack. ## Ownership @@ -39,7 +39,7 @@ with `publishExternally: false`. Continues keep the conversation destination - `runtime/`: native Turn orchestration and provider-neutral delivery ports. - `providers/`: source provider layers around the native runtime. Slack owns its provider runtime in `providers/slack/`. -- `api-turns/`: mailbox enqueue and worker consumer for dashboard/API turns +- `api-turns/`: temporary mailbox enqueue and worker consumer for Conversation API Turns that stay in the conversation log (`publishExternally: false`), including continues of Slack-rooted conversations by verified participants. - `agent-dispatch/`: durable task and plugin dispatch authority, mailbox @@ -136,10 +136,10 @@ delegation without becoming the execution actor or a general task owner. conversation receives its bounded execution destination from its durable agent invocation. - External publish is controlled per turn via `publishExternally`. Slack - ingress/resume publish unless the flag is explicitly false. Non-Slack, - destinationless, and dashboard/web work stay conversation-only unless the - flag is true. Destination presence must not invent publish. A web Source may - keep a Slack Destination when `publishExternally` is false. + ingress and resume publish unless the flag is explicitly false. Non-Slack + and destinationless work stay in the Conversation unless the flag is true. + Destination presence must not invent publish. A Conversation API Turn can + have a Slack Location while its Destination remains the Conversation. - Host-owned runtime context and the actor's current instruction are separate user messages. The context message immediately precedes the instruction, remains context-authority on resume, and may be replaced before a later model diff --git a/packages/junior/src/chat/actor.ts b/packages/junior/src/chat/actor.ts index 0189080fd..8cf173c0b 100644 --- a/packages/junior/src/chat/actor.ts +++ b/packages/junior/src/chat/actor.ts @@ -5,7 +5,7 @@ * remains explicit so durable conversation metadata is not repaired on read. */ import { z } from "zod"; -import { actorSchema } from "@sentry/junior-plugin-api"; +import { actorSchema, type User } from "@sentry/junior-plugin-api"; import { parseSlackTeamId } from "@/chat/slack/ids"; const SLACK_USER_ID_DISPLAY_PATTERN = /^[UW][A-Z0-9]{5,}$/; @@ -43,6 +43,10 @@ export interface LocalActor extends BaseActor { platform: "local"; } +export interface JuniorActor extends BaseActor { + platform: "junior"; +} + export interface WebActor extends BaseActor { platform: "web"; } @@ -52,7 +56,7 @@ export interface SystemActor { name: string; } -export type UserActor = SlackActor | LocalActor | WebActor; +export type UserActor = JuniorActor | SlackActor | LocalActor | WebActor; export type Actor = UserActor | SystemActor; export interface SlackActorProfile { @@ -118,6 +122,20 @@ function cleanActorEmail(value: string | undefined): string | undefined { return email && EMAIL_PATTERN.test(email) ? email : undefined; } +/** Build a Junior Actor from one signed-in user and verified email identity. */ +export function createJuniorActor(user: User): JuniorActor { + const email = cleanActorEmail(user.email)?.toLowerCase(); + if (!email) { + throw new TypeError("Junior Actor requires a verified email"); + } + return { + platform: "junior", + userId: email, + email, + ...(user.displayName ? { fullName: user.displayName } : undefined), + }; +} + /** Keep actor ids exact at platform boundaries before they enter owned state. */ export function parseActorUserId(value: unknown): string | undefined { if (typeof value !== "string" || value.length === 0) { diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 10f9b32cc..a50463d13 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -130,6 +130,10 @@ async function tryRecordSkillLoadStat(skill: Skill) { } type ToolRuntimeRoute = + | Pick< + Extract, + "actor" | "destination" | "source" + > | Pick< Extract, "actor" | "destination" | "source" | "slackActionToken" @@ -153,6 +157,18 @@ function resolveToolRuntimeRoute(args: { }): ToolRuntimeRoute { const destination = toolInvocationDestination(args.run); switch (args.run.source.platform) { + case "junior": { + if (destination.platform !== "junior") { + throw new TypeError( + "Conversation API tool runtime requires a Junior destination", + ); + } + return { + destination, + actor: args.actor?.platform === "junior" ? args.actor : undefined, + source: args.run.source, + }; + } case "slack": { if (destination.platform !== "slack") { throw new TypeError("Slack tool runtime requires a Slack destination"); diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index 81cd17b59..3b34ac599 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -132,9 +132,7 @@ export type AgentRunState = { * The runner must commit the preceding agent boundary before invoking this * port; the accepted reply transaction appends only this message. */ -export type AgentDelivery = ( - message: AssistantMessage, -) => void | Promise; +export type AgentDelivery = (message: AssistantMessage) => void | Promise; /** Resume the agent turn after a transient or ambiguous delivery failure. */ export class RetryableDeliveryError extends Error { @@ -301,9 +299,25 @@ export function assertRunConsistency( // reply container and may already be Slack when a dashboard turn continues a // Slack-rooted conversation without publishing externally. switch (source.platform) { + case "junior": { + if (destination.platform !== "junior") { + throw new TypeError("Conversation API requires a Junior destination"); + } + if ( + source.conversationId !== destination.conversationId || + source.conversationId !== run.conversationId + ) { + throw new TypeError( + "Source, destination, and run Conversation IDs do not match", + ); + } + break; + } case "slack": { if (destination.platform !== "slack") { - throw new TypeError("Run source and destination platforms do not match"); + throw new TypeError( + "Run source and destination platforms do not match", + ); } if (source.teamId !== destination.teamId) { throw new TypeError("Slack source and destination teams do not match"); @@ -312,7 +326,9 @@ export function assertRunConsistency( } case "local": { if (destination.platform !== "local") { - throw new TypeError("Run source and destination platforms do not match"); + throw new TypeError( + "Run source and destination platforms do not match", + ); } if (source.conversationId !== destination.conversationId) { throw new TypeError( @@ -374,6 +390,8 @@ export function assertRunConsistency( } const actorMatchesDestination = (() => { switch (actor.platform) { + case "junior": + return destination.platform === "junior"; case "slack": return destination.platform === "slack"; case "local": @@ -425,6 +443,9 @@ export function surfaceFromRun( if (run.source.platform === "slack") { return "slack"; } + if (run.source.platform === "junior") { + return "api"; + } if (run.source.platform === "web") { // Web/dashboard turns share the non-Slack api surface with agent-dispatch. return "api"; diff --git a/packages/junior/src/chat/api-turns/accepted-reply.ts b/packages/junior/src/chat/api-turns/accepted-reply.ts index 50d19af6d..b325e40a7 100644 --- a/packages/junior/src/chat/api-turns/accepted-reply.ts +++ b/packages/junior/src/chat/api-turns/accepted-reply.ts @@ -4,9 +4,10 @@ import { logException } from "@/chat/logging"; import { recordDeliveredAssistantMessage } from "@/chat/services/conversation-memory"; import { persistWithRetry } from "@/chat/services/persist-retry"; import type { ThreadConversationState } from "@/chat/state/conversation"; +import { apiAssistantMessageId } from "@/chat/api-turns/ids"; -/** Record a web-accepted assistant reply with agent history before the visible message. */ -export async function commitWebAcceptedReply(args: { +/** Record an API assistant reply with agent history before the visible message. */ +export async function commitConversationAcceptedReply(args: { agentMessage?: AssistantMessage; conversation: ThreadConversationState; conversationId: string; @@ -14,17 +15,27 @@ export async function commitWebAcceptedReply(args: { text: string; userMessageId: string; }): Promise { + const ordinal = + args.conversation.messages.filter( + (message) => + message.role === "assistant" && + (message.meta?.turnId === args.sessionId || + message.id.startsWith(`${args.sessionId}:assistant:`)), + ).length + 1; const conversationMessageId = recordDeliveredAssistantMessage({ conversation: args.conversation, + messageId: apiAssistantMessageId(args.sessionId, ordinal), sessionId: args.sessionId, - source: "web", + source: "api", text: args.text, userMessageId: args.userMessageId, }); try { await persistWithRetry(() => commitAcceptedReply({ - ...(args.agentMessage ? { agentMessage: args.agentMessage } : undefined), + ...(args.agentMessage + ? { agentMessage: args.agentMessage } + : undefined), conversation: args.conversation, conversationMessageId, conversationId: args.conversationId, diff --git a/packages/junior/src/chat/api-turns/authorization.ts b/packages/junior/src/chat/api-turns/authorization.ts index 9aa78d717..03d55863d 100644 --- a/packages/junior/src/chat/api-turns/authorization.ts +++ b/packages/junior/src/chat/api-turns/authorization.ts @@ -2,20 +2,22 @@ import { randomUUID } from "node:crypto"; import type { OAuthAuthorizationRequest } from "@/chat/oauth-authorization"; import { getStateAdapter } from "@/chat/state/adapter"; -const WEB_AUTHORIZATION_PREFIX = "junior:web_authorization:v1"; -const WEB_AUTHORIZATION_TTL_MS = 24 * 60 * 60 * 1000; +// TODO(dcramer): After 2026-09-08, replace the stored web prefix after old +// authorization requests expire. +const API_AUTHORIZATION_PREFIX = "junior:web_authorization:v1"; +const API_AUTHORIZATION_TTL_MS = 24 * 60 * 60 * 1000; -export interface WebAuthorizationState extends OAuthAuthorizationRequest { +export interface ApiAuthorizationState extends OAuthAuthorizationRequest { actorId: string; conversationId: string; } function authorizationKey(conversationId: string, actorId: string): string { - return `${WEB_AUTHORIZATION_PREFIX}:${conversationId}:${actorId}`; + return `${API_AUTHORIZATION_PREFIX}:${conversationId}:${actorId}`; } -/** Build authorization delivery for one web turn. */ -export function createWebAuthorization(args: { +/** Build authorization delivery for one Conversation API Turn. */ +export function createApiAuthorization(args: { actorId: string; conversationId: string; }) { @@ -24,24 +26,24 @@ export function createWebAuthorization(args: { deliver: async (request: OAuthAuthorizationRequest) => { await getStateAdapter().set( authorizationKey(args.conversationId, args.actorId), - JSON.stringify({ ...request, ...args } satisfies WebAuthorizationState), - WEB_AUTHORIZATION_TTL_MS, + JSON.stringify({ ...request, ...args } satisfies ApiAuthorizationState), + API_AUTHORIZATION_TTL_MS, ); }, }; } -/** Read the pending authorization request for one web actor. */ -export async function getWebAuthorization(args: { +/** Read the pending authorization request for one API Actor. */ +export async function getApiAuthorization(args: { actorId: string; conversationId: string; -}): Promise { +}): Promise { const value = await getStateAdapter().get( authorizationKey(args.conversationId, args.actorId), ); if (typeof value !== "string") return undefined; try { - const parsed = JSON.parse(value) as Partial; + const parsed = JSON.parse(value) as Partial; if ( parsed.actorId !== args.actorId || parsed.conversationId !== args.conversationId || @@ -51,14 +53,14 @@ export async function getWebAuthorization(args: { ) { return undefined; } - return parsed as WebAuthorizationState; + return parsed as ApiAuthorizationState; } catch { return undefined; } } -/** Remove a web authorization request after the parked turn continues. */ -export async function deleteWebAuthorization(args: { +/** Remove an API authorization request after the paused Turn continues. */ +export async function deleteApiAuthorization(args: { actorId: string; conversationId: string; }): Promise { diff --git a/packages/junior/src/chat/api-turns/cancellation.ts b/packages/junior/src/chat/api-turns/cancellation.ts index f6180e12a..4d3105924 100644 --- a/packages/junior/src/chat/api-turns/cancellation.ts +++ b/packages/junior/src/chat/api-turns/cancellation.ts @@ -1,3 +1,14 @@ +import { deleteApiAuthorization } from "@/chat/api-turns/authorization"; +import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; +import { getConversationEventStore } from "@/chat/db"; +import { markTurnClosed } from "@/chat/runtime/turn"; +import { persistThreadStateById } from "@/chat/runtime/thread-state"; +import type { SandboxRef } from "@/chat/sandbox/ref"; +import { markConversationMessage } from "@/chat/services/conversation-memory"; +import { clearPendingAuth } from "@/chat/services/pending-auth"; +import type { ThreadConversationState } from "@/chat/state/conversation"; +import { abandonTurnRecord } from "@/chat/task-execution/checkpoint"; + /** App-scoped control for one active Turn started by the Conversation API. */ export interface ApiTurnCancellation { begin(conversationId: string): AbortSignal | undefined; @@ -102,7 +113,7 @@ export async function completeCancelledApiTurn(args: { sessionId: args.turnId, }); if (ownsPendingAuthorization) { - await deleteWebAuthorization({ + await deleteApiAuthorization({ actorId: args.actorId, conversationId: args.conversationId, }); @@ -126,13 +137,3 @@ export async function completeCancelledApiTurn(args: { } await args.acknowledge(); } -import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; -import { getConversationEventStore } from "@/chat/db"; -import { deleteWebAuthorization } from "@/chat/api-turns/authorization"; -import { persistThreadStateById } from "@/chat/runtime/thread-state"; -import { markTurnClosed } from "@/chat/runtime/turn"; -import type { SandboxRef } from "@/chat/sandbox/ref"; -import { markConversationMessage } from "@/chat/services/conversation-memory"; -import { clearPendingAuth } from "@/chat/services/pending-auth"; -import type { ThreadConversationState } from "@/chat/state/conversation"; -import { abandonTurnRecord } from "@/chat/task-execution/checkpoint"; diff --git a/packages/junior/src/chat/api-turns/ids.ts b/packages/junior/src/chat/api-turns/ids.ts new file mode 100644 index 000000000..19c3f7dd2 --- /dev/null +++ b/packages/junior/src/chat/api-turns/ids.ts @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; + +/** Build a retry-stable UUID from owned idempotency inputs. */ +function stableUuid(...parts: string[]): string { + const bytes = createHash("sha256") + .update(JSON.stringify(parts)) + .digest() + .subarray(0, 16); + // RFC 9562 version 8 is reserved for application-defined UUIDs. + bytes[6] = (bytes[6]! & 0x0f) | 0x80; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join("-"); +} + +/** Build the retry-stable Conversation id for one user request. */ +export function createApiConversationId(args: { + actorId: string; + idempotencyKey: string; +}): string { + return stableUuid("conversation", args.actorId.trim(), args.idempotencyKey); +} + +/** Build the retry-stable Message id for one Conversation API request. */ +export function apiConversationMessageId(args: { + conversationId: string; + idempotencyKey: string; +}): string { + return stableUuid("message", args.conversationId, args.idempotencyKey); +} + +/** Build the stable Turn id for one Conversation API Message. */ +export function apiTurnIdForMessage(messageId: string): string { + return stableUuid("turn", messageId); +} + +/** Build the stable assistant Message id for one delivery in a Turn. */ +export function apiAssistantMessageId(turnId: string, ordinal: number): string { + return stableUuid("assistant-message", turnId, String(ordinal)); +} diff --git a/packages/junior/src/chat/api-turns/legacy-actor.ts b/packages/junior/src/chat/api-turns/legacy-actor.ts new file mode 100644 index 000000000..cf3c1123b --- /dev/null +++ b/packages/junior/src/chat/api-turns/legacy-actor.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { createUserTokenStore } from "@/chat/capabilities/factory"; +import { + getMcpStoredOAuthCredentials, + putMcpStoredOAuthCredentials, +} from "@/chat/mcp/auth-store"; +import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; + +/** Return the Actor id used by Conversation API work before Junior identities. */ +export function legacyApiActorId(email: string): string { + const normalized = email.trim().toLowerCase(); + const suffix = createHash("sha256") + .update(normalized) + .digest("hex") + .slice(0, 24); + return `dashboard:${suffix}`; +} + +/** Copy credentials from the old API Actor id when the current Actor has none. */ +export async function copyLegacyApiActorCredentials(args: { + actorId: string; + email: string; +}): Promise { + const legacyActorId = legacyApiActorId(args.email); + if (legacyActorId === args.actorId) { + return; + } + + // TODO(dcramer): After 2026-09-08, remove this copy. Work saved before the + // Actor id change will have finished. Keep old keys until callbacks finish. + const pluginProviders = pluginCatalogRuntime + .getProviders() + .filter((plugin) => Boolean(plugin.manifest.oauth)) + .map((plugin) => plugin.manifest.name); + const mcpProviders = pluginCatalogRuntime + .getMcpProviders() + .map((plugin) => plugin.manifest.name); + const tokenStore = createUserTokenStore(); + await Promise.all( + [...new Set(pluginProviders)].map(async (provider) => { + if (await tokenStore.get(args.actorId, provider)) { + return; + } + const tokens = await tokenStore.get(legacyActorId, provider); + if (tokens) { + await tokenStore.set(args.actorId, provider, tokens); + } + }), + ); + await Promise.all( + [...new Set(mcpProviders)].map(async (provider) => { + if (await getMcpStoredOAuthCredentials(args.actorId, provider)) { + return; + } + const credentials = await getMcpStoredOAuthCredentials( + legacyActorId, + provider, + ); + if (credentials) { + await putMcpStoredOAuthCredentials(args.actorId, provider, credentials); + } + }), + ); +} diff --git a/packages/junior/src/chat/api-turns/routing.ts b/packages/junior/src/chat/api-turns/routing.ts index 825e33e25..d2c4e4c58 100644 --- a/packages/junior/src/chat/api-turns/routing.ts +++ b/packages/junior/src/chat/api-turns/routing.ts @@ -36,7 +36,7 @@ export async function getActiveApiTurnId( ); if (active.length > 1) { throw new Error( - `Conversation ${conversationId} has multiple active web turns`, + `Conversation ${conversationId} has multiple active API Turns`, ); } const turnId = active[0]?.turnId; @@ -76,11 +76,11 @@ function parseApiTurnMessages( return []; } if (parsed.some((entry) => !entry.metadata.success)) { - throw new Error("Conversation mailbox mixes web turns and other input"); + throw new Error("Conversation mailbox mixes API Turns and other input"); } return parsed.map((entry) => { if (!entry.metadata.success) { - throw new Error("API turn mailbox metadata failed validation"); + throw new Error("API Turn mailbox metadata failed validation"); } return { message: entry.message, metadata: entry.metadata.data }; }); diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index 3f45993f0..d46a9ecff 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -1,23 +1,23 @@ /** Conversation API work runs a native Turn and keeps replies in the Conversation. */ -import { createHash } from "node:crypto"; +import { randomUUID } from "node:crypto"; import type { StateAdapter } from "chat"; import { - createWebSource, - localDestinationSchema, + createJuniorSource, + juniorDestinationSchema, type Destination, - type LocalDestination, + type JuniorActor, + type JuniorDestination, type Source, } from "@sentry/junior-plugin-api"; import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import type { AssistantMessage } from "@earendil-works/pi-ai"; -import type { WebActor } from "@/chat/actor"; import type { ConversationStore } from "@/chat/conversations/store"; import { loadProjection } from "@/chat/conversations/projection"; import { hydrateConversationMessages, persistConversationMessages, } from "@/chat/conversations/messages"; -import { commitWebAcceptedReply } from "@/chat/api-turns/accepted-reply"; +import { commitConversationAcceptedReply } from "@/chat/api-turns/accepted-reply"; import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; import type { ConversationTurnFailureCode } from "@/chat/conversations/history"; import { credentialContextForActor } from "@/chat/credentials/context"; @@ -49,8 +49,10 @@ import { upsertConversationMessage, } from "@/chat/services/conversation-memory"; import { finalizeFailedTurnReplyWithEvent } from "@/chat/services/turn-failure-response"; -import { coerceThreadConversationState } from "@/chat/state/conversation"; -import { buildDeterministicTurnId } from "@/chat/state/turn-id"; +import { + coerceThreadConversationState, + type ConversationMessage, +} from "@/chat/state/conversation"; import { appendAndEnqueueInboundMessage, appendAndEnqueueExclusiveInboundMessage, @@ -74,8 +76,8 @@ import { import type { SandboxRef } from "@/chat/sandbox/ref"; import type { StoredSlackActor } from "@/chat/actor"; import { - createWebAuthorization, - deleteWebAuthorization, + createApiAuthorization, + deleteApiAuthorization, } from "@/chat/api-turns/authorization"; import { completeCancelledApiTurn, @@ -86,6 +88,12 @@ import { resolveApiTurnWork, type ApiTurnMailboxMetadata, } from "@/chat/api-turns/routing"; +import { + apiConversationMessageId, + apiTurnIdForMessage, + createApiConversationId, +} from "@/chat/api-turns/ids"; +import { copyLegacyApiActorCredentials } from "@/chat/api-turns/legacy-actor"; export { resolveApiTurnWork } from "@/chat/api-turns/routing"; @@ -97,7 +105,7 @@ type EnqueueOptions = { }; export interface CreateApiConversationInput { - actor: WebActor; + actor: JuniorActor; message: string; /** Client-supplied idempotency key for the first message. */ idempotencyKey: string; @@ -106,7 +114,7 @@ export interface CreateApiConversationInput { } export interface AppendApiConversationMessageInput { - actor: WebActor; + actor: JuniorActor; conversationId: string; message: string; idempotencyKey: string; @@ -128,78 +136,38 @@ function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } -function stableHex(...parts: string[]): string { - return createHash("sha256") - .update(parts.join("\u0000")) - .digest("hex") - .slice(0, 24); -} - -/** - * Build a durable API conversation id for one viewer + create key. - * - * Retries of POST /api/conversations with the same key must address the same - * conversation before the mailbox message id is derived. - */ -export function createApiConversationId(args: { - actorEmail: string; - idempotencyKey: string; -}): string { - return `local:web:${stableHex( - normalizeEmail(args.actorEmail), - args.idempotencyKey, - )}`; -} - -/** Build the retry-stable Message id used by one API mailbox request. */ -export function apiConversationMessageId(args: { - conversationId: string; - idempotencyKey: string; -}): string { - return `api-msg:${stableHex(args.conversationId, args.idempotencyKey)}`; -} - -/** Stable turn id for one API mailbox message (matches getTurnUserMessage). */ -export function apiTurnIdForMessage(messageId: string): string { - return buildDeterministicTurnId(messageId); -} - -function requireLocalDestination(conversationId: string): LocalDestination { - const parsed = localDestinationSchema.safeParse({ - platform: "local", +function requireJuniorDestination(conversationId: string): JuniorDestination { + const parsed = juniorDestinationSchema.safeParse({ + platform: "junior", conversationId, }); if (!parsed.success) { - throw new Error(`Invalid local conversation id: ${conversationId}`); + throw new Error(`Invalid Conversation id: ${conversationId}`); } return parsed.data; } -/** Keep an existing destination, or create a local one for new dashboard roots. */ +/** Use the Conversation as the API Turn Destination. */ function resolveApiTurnDestination(args: { conversationId: string; - existing?: Destination; -}): Destination { - if (args.existing) { - return args.existing; - } - return requireLocalDestination(args.conversationId); +}): JuniorDestination { + return requireJuniorDestination(args.conversationId); } -/** Build the web Source for one dashboard turn, inheriting conversation privacy. */ -function webSourceForConversation(args: { +/** Build the Conversation API Source with the stored Conversation privacy. */ +function apiSourceForConversation(args: { conversationId: string; visibility?: ConversationPrivacy; }): Source { - return createWebSource( + return createJuniorSource( args.conversationId, args.visibility === "private" ? "private" : "public", ); } -function actorFromMetadata(metadata: ApiTurnMailboxMetadata): WebActor { +function actorFromMetadata(metadata: ApiTurnMailboxMetadata): JuniorActor { return { - platform: "web", + platform: "junior", userId: metadata.authorUserId, email: normalizeEmail(metadata.authorEmail), ...(metadata.authorFullName @@ -211,48 +179,40 @@ function actorFromMetadata(metadata: ApiTurnMailboxMetadata): WebActor { }; } -/** Durable conversation actor fields for web/dashboard participants. */ -function storedActorFromApi(actor: WebActor): StoredSlackActor { +/** Durable participant fields used by the Conversation store. */ +function storedActorFromApi(actor: JuniorActor): StoredSlackActor { return { ...(actor.email ? { email: normalizeEmail(actor.email) } : undefined), ...(actor.fullName ? { fullName: actor.fullName } : undefined), }; } -/** Rebuild the dashboard actor from durable conversation identity. */ -export function webActorFromEmail( - email: string, - profile?: { fullName?: string; userName?: string }, -): WebActor { - const normalized = normalizeEmail(email); +function actorFromTurnMessage( + message: ConversationMessage, + actorId: string, +): JuniorActor { + if (message.author?.userId !== actorId) { + throw new Error(`Turn Actor does not match Message ${message.id}`); + } return { - platform: "web", - userId: `dashboard:${stableHex(normalized)}`, - email: normalized, - ...(profile?.fullName ? { fullName: profile.fullName } : undefined), - ...(profile?.userName ? { userName: profile.userName } : undefined), + platform: "junior", + userId: actorId, + ...(message.author.email ? { email: message.author.email } : undefined), + ...(message.author.fullName + ? { fullName: message.author.fullName } + : undefined), + ...(message.author.userName + ? { userName: message.author.userName } + : undefined), }; } -function actorFromStoredConversation( - stored?: StoredSlackActor, -): WebActor | undefined { - const email = stored?.email?.trim().toLowerCase(); - if (!email) { - return undefined; - } - return webActorFromEmail( - email, - stored?.fullName ? { fullName: stored.fullName } : undefined, - ); -} - /** Build one API mailbox entry with conversation-only publish. */ export function buildApiTurnInboundMessage(args: { - actor: WebActor; + actor: JuniorActor; conversationId: string; createdAtMs?: number; - /** Existing conversation destination; required when continuing a provider root. */ + /** Stored provider Destination kept until the queue stores Location separately. */ destination?: Destination; message: string; messageId: string; @@ -265,10 +225,9 @@ export function buildApiTurnInboundMessage(args: { if (!args.actor.email) { throw new Error("API conversation actor requires a verified email"); } - const destination = resolveApiTurnDestination({ - conversationId: args.conversationId, - existing: args.destination, - }); + const destination = + args.destination ?? + resolveApiTurnDestination({ conversationId: args.conversationId }); const nowMs = args.nowMs ?? Date.now(); return { conversationId: args.conversationId, @@ -290,13 +249,13 @@ export function buildApiTurnInboundMessage(args: { }, receivedAtMs: nowMs, publishExternally: false, - source: "web", + source: "api", }; } -/** Record web activity and materialize a new API Conversation root when needed. */ +/** Record API activity and create a Conversation root when needed. */ export async function recordApiConversationActivity(args: { - actor: WebActor; + actor: JuniorActor; conversationId: string; conversationStore?: ConversationStore; nowMs: number; @@ -305,35 +264,35 @@ export async function recordApiConversationActivity(args: { }): Promise { const store = args.conversationStore ?? getConversationStore(); const existing = await store.get({ conversationId: args.conversationId }); - const destination = resolveApiTurnDestination({ + const apiDestination = resolveApiTurnDestination({ conversationId: args.conversationId, - existing: existing?.destination, }); - // New dashboard roots default public. Continues inherit the existing root + // New API roots default public. Continues inherit the existing root // visibility and keep the original session source (set-once). const isNewRoot = !existing; const visibility = args.rootVisibility === "private" ? "private" : "public"; const source = isNewRoot - ? webSourceForConversation({ + ? apiSourceForConversation({ conversationId: args.conversationId, visibility, }) : undefined; + const storedDestination = existing?.destination ?? apiDestination; await store.recordActivity({ conversationId: args.conversationId, - destination, + destination: storedDestination, nowMs: args.nowMs, actor: storedActorFromApi(args.actor), - // Do not rewrite a Slack root's origin source when a dashboard participant - // continues it. Mailbox entries still carry source "web" per turn. - ...(isNewRoot ? { source: "web" as const } : undefined), + // Do not rewrite a Slack root's origin source when an API participant + // continues it. The mailbox entry still identifies this API Turn. + ...(isNewRoot ? { source: "api" as const } : undefined), ...(source ? { sessionSource: source } : undefined), ...(isNewRoot ? { visibility } : undefined), }); - return destination; + return storedDestination; } -/** Create a dashboard root conversation and enqueue its first message. */ +/** Create a Conversation API root and enqueue its first message. */ export async function createAndEnqueueApiConversation( input: CreateApiConversationInput, options: EnqueueOptions, @@ -342,7 +301,7 @@ export async function createAndEnqueueApiConversation( throw new Error("API conversation actor requires a verified email"); } const conversationId = createApiConversationId({ - actorEmail: input.actor.email, + actorId: input.actor.userId, idempotencyKey: input.idempotencyKey, }); return await appendAndEnqueueApiConversationMessage( @@ -377,6 +336,10 @@ export async function appendAndEnqueueApiConversationMessage( if (!input.actor.email) { throw new Error("API conversation actor requires a verified email"); } + await copyLegacyApiActorCredentials({ + actorId: input.actor.userId, + email: input.actor.email, + }); const nowMs = options.nowMs ?? Date.now(); const messageId = apiConversationMessageId({ conversationId: input.conversationId, @@ -459,7 +422,7 @@ export function createApiTurnWorker( ); const isResume = resolved.kind === "resume"; - let actor: WebActor; + let actor: JuniorActor; let text: string; let turnId: string; let userMessageId: string; @@ -469,6 +432,12 @@ export function createApiTurnWorker( const storedConversation = await getConversationStore().get({ conversationId: context.conversationId, }); + const persisted = await getPersistedThreadState(context.conversationId); + const conversation = coerceThreadConversationState(persisted); + await hydrateConversationMessages({ + conversation, + conversationId: context.conversationId, + }); if (resolved.kind === "mailbox") { const first = resolved.batch[0]!; @@ -483,36 +452,47 @@ export function createApiTurnWorker( inputMessageIds = resolved.batch.map((entry) => entry.metadata.messageId); } else { turnId = resolved.turnId; - // Execution actor is rebuilt at resume from the durable conversation - // identity. For Slack-rooted continues, the root actor keeps the verified - // participant email used by dashboard access. - const resumedActor = actorFromStoredConversation( - storedConversation?.actor, - ); - if (!resumedActor) { + const userMessage = getTurnUserMessage(conversation, turnId); + if (!userMessage) { throw new Error( - `Conversation API resume missing actor for ${context.conversationId}`, + `Unable to locate the persisted user message for Turn "${turnId}"`, ); } - actor = resumedActor; - // User message text/id are recovered from thread state below. - text = ""; - userMessageId = ""; - startedAtMs = Date.now(); - inputMessageIds = []; + const pendingActorId = + conversation.processing.pendingAuth?.sessionId === turnId + ? conversation.processing.pendingAuth.actorId + : undefined; + let actorId = + conversation.processing.activeTurnId === turnId + ? conversation.processing.activeActorId + : pendingActorId; + if (!actorId) { + // TODO(dcramer): After 2026-09-08, remove this fallback once every + // resumable API Turn has an activeActorId or pendingAuth actorId. + const record = await getTurnRecord(context.conversationId, turnId); + const savedActors = (record?.actors ?? []).filter( + (candidate) => candidate.platform !== "system", + ); + if (savedActors.length === 1) { + actorId = savedActors[0]!.userId; + } + } + if (!actorId) { + throw new Error( + `Conversation API resume missing Actor for ${context.conversationId}`, + ); + } + actor = actorFromTurnMessage(userMessage, actorId); + text = userMessage.text; + userMessageId = userMessage.id; + startedAtMs = userMessage.createdAtMs; + inputMessageIds = [userMessageId]; } - // Prefer the leased mailbox destination, then durable conversation state. const destination = resolveApiTurnDestination({ conversationId: context.conversationId, - existing: - context.destination ?? - (resolved.kind === "mailbox" - ? resolved.batch[0]?.message.destination - : undefined) ?? - storedConversation?.destination, }); - const source = webSourceForConversation({ + const source = apiSourceForConversation({ conversationId: context.conversationId, visibility: storedConversation?.visibility, }); @@ -520,7 +500,7 @@ export function createApiTurnWorker( return await withLogContext( { conversationId: context.conversationId, - platform: "web", + platform: "junior", userId: actor.userId, ...(actor.userName ? { userName: actor.userName } : undefined), }, @@ -540,12 +520,6 @@ export function createApiTurnWorker( acknowledged = true; }; - const persisted = await getPersistedThreadState(context.conversationId); - const conversation = coerceThreadConversationState(persisted); - await hydrateConversationMessages({ - conversation, - conversationId: context.conversationId, - }); let sandboxRef: SandboxRef | undefined = getPersistedSandboxState(persisted); const initialSandboxRef = sandboxRef; @@ -584,16 +558,6 @@ export function createApiTurnWorker( }; if (isResume) { - const userMessage = getTurnUserMessage(conversation, turnId); - if (!userMessage) { - throw new Error( - `Unable to locate the persisted user message for Turn "${turnId}"`, - ); - } - userMessageId = userMessage.id; - text = userMessage.text; - startedAtMs = userMessage.createdAtMs; - inputMessageIds = [userMessageId]; if (cancellationSignal?.aborted) { return await completeCancelledTurn(); } @@ -612,7 +576,8 @@ export function createApiTurnWorker( meta: { explicitMention: true, replied: false, - source: "web", + source: "api", + turnId, }, }); await persistConversationMessages({ @@ -630,6 +595,7 @@ export function createApiTurnWorker( return await completeCancelledTurn(); } startActiveTurn({ + actorId: actor.userId, conversation, nextTurnId: turnId, }); @@ -653,7 +619,7 @@ export function createApiTurnWorker( } failureCode = "delivery_failed"; assistantMessageDelivered = true; - await commitWebAcceptedReply({ + await commitConversationAcceptedReply({ ...(agentMessage ? { agentMessage } : undefined), conversation, conversationId: context.conversationId, @@ -701,9 +667,9 @@ export function createApiTurnWorker( }); } if (authParked.length > 0) { - // Drop the dashboard connect prompt so a superseded OAuth flow + // Drop the connect prompt so a replaced OAuth flow // cannot leave a stale banner after the user moves on. - await deleteWebAuthorization({ + await deleteApiAuthorization({ actorId: actor.userId, conversationId: context.conversationId, }); @@ -719,7 +685,7 @@ export function createApiTurnWorker( conversationId: context.conversationId, }); failureCode = "agent_run_failed"; - currentRunId = `api-run:${stableHex(turnId, String(startedAtMs))}`; + currentRunId = randomUUID(); setTags({ runId: currentRunId }); const outcome = await executeTurn( @@ -738,13 +704,14 @@ export function createApiTurnWorker( actor, credentialContext: credentialContextForActor(actor), destination, + destinationVisibility: storedConversation?.visibility, publishExternally: false, source, surface: "api", ...(cancellationSignal ? { signal: cancellationSignal } : undefined), - authorization: createWebAuthorization({ + authorization: createApiAuthorization({ actorId: actor.userId, conversationId: context.conversationId, }), diff --git a/packages/junior/src/chat/app/factory.ts b/packages/junior/src/chat/app/factory.ts index b238980eb..b34e55da3 100644 --- a/packages/junior/src/chat/app/factory.ts +++ b/packages/junior/src/chat/app/factory.ts @@ -60,6 +60,7 @@ function clearSkippedTurnIfActive( conversation.processing.activeTurnId === buildDeterministicTurnId(messageId) ) { conversation.processing.activeTurnId = undefined; + conversation.processing.activeActorId = undefined; } } diff --git a/packages/junior/src/chat/conversations/destination-visibility.ts b/packages/junior/src/chat/conversations/destination-visibility.ts index a98246600..5017933b0 100644 --- a/packages/junior/src/chat/conversations/destination-visibility.ts +++ b/packages/junior/src/chat/conversations/destination-visibility.ts @@ -13,6 +13,13 @@ export async function resolveDestinationVisibility(args: { if (args.destination.platform === "local") { return "private"; } + if (args.destination.platform === "junior") { + return ( + await getConversationStore().get({ + conversationId: args.destination.conversationId, + }) + )?.visibility; + } return await getConversationStore().getDestinationVisibility({ provider: "slack", providerDestinationId: args.destination.channelId, diff --git a/packages/junior/src/chat/conversations/sql/location.ts b/packages/junior/src/chat/conversations/sql/location.ts index 1e97cf42a..6c2f5870d 100644 --- a/packages/junior/src/chat/conversations/sql/location.ts +++ b/packages/junior/src/chat/conversations/sql/location.ts @@ -6,7 +6,7 @@ type LocationRow = typeof juniorDestinations.$inferSelect; /** Project one supported provider location from its linked SQL row. */ export function locationFromRow(row: LocationRow | null): Location | undefined { - if (!row || row.provider === "local") { + if (!row || row.provider === "junior" || row.provider === "local") { return undefined; } return locationSchema.parse({ diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index f20ab32ab..28bccb1b3 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -167,6 +167,16 @@ function destinationUpsertFromDestination(args: { metadata: { platform: "slack" }, }; } + if (destination.platform === "junior") { + return { + kind: "conversation", + provider: "junior", + providerDestinationId: destination.conversationId, + refreshVisibility: args.visibility !== undefined, + visibility: args.visibility ?? "private", + metadata: { platform: "junior" }, + }; + } return { kind: "local_conversation", provider: "local", @@ -175,7 +185,7 @@ function destinationUpsertFromDestination(args: { localWorkspaceFromConversationId(args.conversationId ?? ""), providerDestinationId: destination.conversationId, // Match Slack: only refresh when a live visibility signal is present so - // execution-metadata writes cannot clobber public dashboard roots. + // execution-metadata writes cannot clobber public local roots. refreshVisibility: args.visibility !== undefined, visibility: args.visibility ?? "direct", metadata: { platform: "local" }, @@ -210,12 +220,17 @@ function destinationFromRow( teamId: destination.providerTenantId, channelId: destination.providerDestinationId, } - : destination?.provider === "local" + : destination?.provider === "junior" ? { - platform: "local", + platform: "junior", conversationId: destination.providerDestinationId, } - : undefined; + : destination?.provider === "local" + ? { + platform: "local", + conversationId: destination.providerDestinationId, + } + : undefined; return parseDestination(value); } diff --git a/packages/junior/src/chat/destination.ts b/packages/junior/src/chat/destination.ts index 69f1417de..70ee8babf 100644 --- a/packages/junior/src/chat/destination.ts +++ b/packages/junior/src/chat/destination.ts @@ -49,6 +49,9 @@ export function sameDestination( if (left.platform === "local" && right.platform === "local") { return left.conversationId === right.conversationId; } + if (left.platform === "junior" && right.platform === "junior") { + return left.conversationId === right.conversationId; + } if (left.platform === "slack" && right.platform === "slack") { return left.teamId === right.teamId && left.channelId === right.channelId; } @@ -57,7 +60,7 @@ export function sameDestination( /** Return the lock/index-safe storage key for a destination. */ export function destinationKey(destination: Destination): string { - if (destination.platform === "local") { + if (destination.platform === "junior" || destination.platform === "local") { return destination.conversationId; } return `slack:${destination.teamId}:${destination.channelId}`; diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 77ca7e636..7bd26cc71 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -173,6 +173,19 @@ function pluginInvocationContext( locationId: context.locationId, }; switch (context.source.platform) { + case "junior": { + if (context.destination.platform !== "junior") { + throw new TypeError( + "Conversation API plugin context requires a Junior destination", + ); + } + return { + ...common, + actor: context.actor?.platform === "junior" ? context.actor : undefined, + destination: context.destination, + source: context.source, + }; + } case "slack": { if (context.destination.platform !== "slack") { throw new TypeError("Slack plugin context requires Slack destination"); @@ -704,6 +717,20 @@ export function getPluginTools( }; let pluginContext: ToolRegistrationHookContext; switch (context.source.platform) { + case "junior": + if (context.destination.platform !== "junior") { + throw new TypeError( + "Conversation API plugin context requires a Junior destination", + ); + } + pluginContext = { + ...common, + actor: + context.actor?.platform === "junior" ? context.actor : undefined, + destination: context.destination, + source: context.source, + }; + break; case "slack": if (context.destination.platform !== "slack") { throw new TypeError( diff --git a/packages/junior/src/chat/plugins/task-runner.ts b/packages/junior/src/chat/plugins/task-runner.ts index b870aec2f..2f3568efa 100644 --- a/packages/junior/src/chat/plugins/task-runner.ts +++ b/packages/junior/src/chat/plugins/task-runner.ts @@ -275,6 +275,7 @@ async function loadConversationContextTranscriptEntries( } break; case "web": + case "junior": case "local": return []; } diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index a76cb4f00..7a7fa75e3 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -270,7 +270,7 @@ function formatConfigurationLines( ); } -type PromptPlatform = Platform; +type PromptPlatform = Exclude; const SLACK_HEADER = "You are a Slack-based helper assistant. Follow the personality section for voice and tone in every reply. Platform mechanics and output rules override personality and world context when they conflict."; @@ -443,6 +443,7 @@ function buildRuntimeSection(params: { function formatSourceLines(source: Source): string[] { switch (source.platform) { + case "junior": case "web": case "local": return [ @@ -465,9 +466,9 @@ function formatSourceLines(source: Source): string[] { } function formatDestinationLines(destination: Destination): string[] { - if (destination.platform === "local") { + if (destination.platform === "junior" || destination.platform === "local") { return [ - "- destination.platform: local", + `- destination.platform: ${destination.platform}`, `- destination.conversation_id: ${escapeXml(destination.conversationId)}`, ]; } @@ -692,7 +693,7 @@ const STATIC_SYSTEM_PROMPTS: Record = { /** Return byte-stable platform instructions shared by every conversation and turn. */ export function buildSystemPrompt(params: { source: Source }): string { - // web/dashboard turns use the local (non-Slack) instruction surface. + // Conversation API and local Turns use the non-Slack instructions. const platform: PromptPlatform = params.source.platform === "slack" ? "slack" : "local"; return STATIC_SYSTEM_PROMPTS[platform]; diff --git a/packages/junior/src/chat/runtime/turn-user-message.ts b/packages/junior/src/chat/runtime/turn-user-message.ts index 820f05b80..0a2e71c62 100644 --- a/packages/junior/src/chat/runtime/turn-user-message.ts +++ b/packages/junior/src/chat/runtime/turn-user-message.ts @@ -18,7 +18,10 @@ export function getTurnUserMessage( if (message?.role !== "user") { continue; } - if (buildDeterministicTurnId(message.id) === sessionId) { + if ( + message.meta?.turnId === sessionId || + buildDeterministicTurnId(message.id) === sessionId + ) { return message; } } diff --git a/packages/junior/src/chat/runtime/turn.ts b/packages/junior/src/chat/runtime/turn.ts index 63b263629..50a7f80e4 100644 --- a/packages/junior/src/chat/runtime/turn.ts +++ b/packages/junior/src/chat/runtime/turn.ts @@ -96,9 +96,11 @@ export function isTurnInputDeferredError( /** Mark a turn as the active turn in conversation state. */ export function startActiveTurn(args: { + actorId?: string; conversation: ThreadConversationState; nextTurnId: string; }): void { + args.conversation.processing.activeActorId = args.actorId; args.conversation.processing.activeTurnId = args.nextTurnId; } @@ -107,6 +109,7 @@ function clearActiveTurn( sessionId?: string, ): void { if (!sessionId || conversation.processing.activeTurnId === sessionId) { + conversation.processing.activeActorId = undefined; conversation.processing.activeTurnId = undefined; } } diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index 042ab058e..cc0cbff88 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -151,8 +151,9 @@ export function upsertConversationMessage( /** Record one assistant message after its destination accepts it. */ export function recordDeliveredAssistantMessage(args: { conversation: ThreadConversationState; + messageId?: string; sessionId: string; - source?: "slack" | "web"; + source?: "api" | "slack" | "web"; text: string; userMessageId?: string; }): string { @@ -161,7 +162,7 @@ export function recordDeliveredAssistantMessage(args: { args.conversation.messages.filter((message) => message.id.startsWith(prefix), ).length + 1; - const messageId = `${prefix}${ordinal}`; + const messageId = args.messageId ?? `${prefix}${ordinal}`; markConversationMessage(args.conversation, args.userMessageId, { replied: true, skippedReason: undefined, @@ -177,6 +178,7 @@ export function recordDeliveredAssistantMessage(args: { }, meta: { replied: true, + turnId: args.sessionId, ...(args.source ? { source: args.source } : undefined), }, }); @@ -191,7 +193,9 @@ export function turnHasReply( const assistantPrefix = `${turnId}:assistant:`; return conversation.messages.some( (message) => - message.role === "assistant" && message.id.startsWith(assistantPrefix), + message.role === "assistant" && + (message.meta?.turnId === turnId || + message.id.startsWith(assistantPrefix)), ); } diff --git a/packages/junior/src/chat/services/pending-auth.ts b/packages/junior/src/chat/services/pending-auth.ts index 9fa3e8cfa..c9240d535 100644 --- a/packages/junior/src/chat/services/pending-auth.ts +++ b/packages/junior/src/chat/services/pending-auth.ts @@ -142,7 +142,10 @@ export function isPendingAuthLatestRequest( if (isSkippedNonRequest(message.meta?.skippedReason)) { continue; } - return buildDeterministicTurnId(message.id) === pendingAuth.sessionId; + return ( + message.meta?.turnId === pendingAuth.sessionId || + buildDeterministicTurnId(message.id) === pendingAuth.sessionId + ); } return false; diff --git a/packages/junior/src/chat/source.ts b/packages/junior/src/chat/source.ts index d330597ce..6fd3c831f 100644 --- a/packages/junior/src/chat/source.ts +++ b/packages/junior/src/chat/source.ts @@ -2,6 +2,7 @@ import { sourceSchema, type Source } from "@sentry/junior-plugin-api"; /** Source coordinates reduced to the stable locator for one conversation. */ export type SessionSource = + | Extract | Extract | Extract | Omit, "messageTs">; @@ -18,6 +19,13 @@ export function normalizeSessionSource( if (!value) { return undefined; } + if (value.platform === "junior") { + return { + platform: "junior", + visibility: value.visibility, + conversationId: value.conversationId, + }; + } if (value.platform === "local") { return { platform: "local", diff --git a/packages/junior/src/chat/state/conversation.ts b/packages/junior/src/chat/state/conversation.ts index 3dbfd7130..490d48c09 100644 --- a/packages/junior/src/chat/state/conversation.ts +++ b/packages/junior/src/chat/state/conversation.ts @@ -13,7 +13,7 @@ export interface ConversationAuthor { export interface ConversationMessageMeta { attachmentCount?: number; /** Known message provenance. Omit when unknown; never invent a default. */ - source?: "slack" | "web"; + source?: "api" | "slack" | "web"; eventType?: string; explicitMention?: boolean; imageAttachmentCount?: number; @@ -22,6 +22,8 @@ export interface ConversationMessageMeta { replied?: boolean; slackTs?: string; skippedReason?: string; + /** Turn that owns this durable Message. */ + turnId?: string; } export interface ConversationMessage { @@ -41,6 +43,8 @@ export interface ConversationCompaction { } export interface ConversationProcessingState { + /** Actor for the active Turn. */ + activeActorId?: string; activeTurnId?: string; lastCompletedAtMs?: number; pendingAuth?: ConversationPendingAuthState; @@ -151,6 +155,7 @@ export function coerceThreadConversationState( ? rawConversation.processing : {}; const processing: ConversationProcessingState = { + activeActorId: toOptionalString(rawProcessing.activeActorId), activeTurnId: toOptionalString(rawProcessing.activeTurnId), lastCompletedAtMs: toOptionalNumber(rawProcessing.lastCompletedAtMs), pendingAuth: coercePendingAuthState(rawProcessing.pendingAuth), diff --git a/packages/junior/src/chat/task-execution/state.ts b/packages/junior/src/chat/task-execution/state.ts index 536be117d..abedc9e22 100644 --- a/packages/junior/src/chat/task-execution/state.ts +++ b/packages/junior/src/chat/task-execution/state.ts @@ -1691,7 +1691,11 @@ export async function ackMessages(args: { } function isHumanFacingMessage(message: InboundMessage): boolean { - return message.source === "web" || message.source === "slack"; + return ( + message.source === "api" || + message.source === "web" || + message.source === "slack" + ); } /** Persist a stop request for the current run without process affinity. */ diff --git a/packages/junior/src/chat/task-execution/turn-cursor.ts b/packages/junior/src/chat/task-execution/turn-cursor.ts index f8c11fd8c..ab485d32a 100644 --- a/packages/junior/src/chat/task-execution/turn-cursor.ts +++ b/packages/junior/src/chat/task-execution/turn-cursor.ts @@ -36,6 +36,7 @@ import { import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import type { ConversationExecution, + ConversationSource, ConversationStore, } from "@/chat/conversations/store"; import { @@ -348,21 +349,33 @@ async function recordConversationActivityMetadata(args: { // Nested destination/source/actor stay off Redis cursor payloads; callers // pass live routing/identity here for SQL dual-write. Child conversations stay // destinationless. - const destination = isChild ? undefined : args.destination; + // TODO(dcramer): After 2026-09-08, store provider Location separately from + // the queue Destination and remove this temporary rule. Conversation API + // runs now target the Conversation log and must not replace a Slack root's + // location while SQL still derives Location from the destination row. + const destination = isChild + ? undefined + : args.destination?.platform === "junior" && + conversation?.destination?.platform !== "junior" + ? conversation?.destination + : args.destination; // Only derive ConversationSource when routing is known. Abandon/fail no longer // carry nested destination, and SQL coalesce(excluded, existing) would otherwise // overwrite a durable `local` source with surface `internal`. - // Prefer the typed session Source branch when present so web/dashboard turns - // are not collapsed to local just because delivery uses a local destination. - const activitySource = isChild - ? "internal" - : args.source?.platform === "web" - ? "web" - : destination?.platform === "local" - ? "local" - : destination - ? args.summary.surface - : undefined; + // Prefer the typed session Source branch when present so API turns are not + // collapsed to their stored provider location. + let activitySource: ConversationSource | undefined; + if (isChild) { + activitySource = "internal"; + } else if (args.source?.platform === "junior") { + activitySource = "api"; + } else if (args.source?.platform === "web") { + activitySource = "web"; + } else if (destination?.platform === "local") { + activitySource = "local"; + } else if (destination) { + activitySource = args.summary.surface; + } await conversationStore.recordActivity({ activityAtMs: args.summary.updatedAtMs, channelName: args.summary.channelName, @@ -915,7 +928,8 @@ async function upsertTurnRecordLocked( errorMessage: args.errorMessage, lastProgressAtMs: args.lastProgressAtMs, resumeReason: args.resumeReason, - publishExternally: args.publishExternally ?? existingRecord?.publishExternally, + publishExternally: + args.publishExternally ?? existingRecord?.publishExternally, resultMessageId: args.resultMessageId ?? existingRecord?.resultMessageId, resumedFromSliceId: args.resumedFromSliceId, diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 442fcc954..4535f5766 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -3,6 +3,8 @@ import type { WebSource, Destination, Identity, + JuniorDestination, + JuniorSource, LocalDestination, LocalSource, PluginEgress, @@ -16,7 +18,13 @@ import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import type { AgentTurnSurface } from "@/chat/task-execution/checkpoint"; import type { Skill } from "@/chat/skills"; import type { JuniorToolOutput } from "@/chat/tool-support/structured-result"; -import type { WebActor, LocalActor, Actor, SlackActor } from "@/chat/actor"; +import type { + Actor, + JuniorActor, + LocalActor, + SlackActor, + WebActor, +} from "@/chat/actor"; import type { SlackActionToken } from "@/chat/slack/action-token"; import type { ModelProfile } from "@/chat/model-profile"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; @@ -118,6 +126,14 @@ interface SlackToolRuntimeContext extends BaseToolRuntimeContext { slackActionToken?: SlackActionToken; } +interface JuniorToolRuntimeContext extends BaseToolRuntimeContext { + destination: JuniorDestination; + actor?: JuniorActor; + source: JuniorSource; + slack?: never; + slackActionToken?: never; +} + interface LocalToolRuntimeContext extends BaseToolRuntimeContext { destination: LocalDestination; actor?: LocalActor; @@ -135,6 +151,7 @@ interface WebToolRuntimeContext extends BaseToolRuntimeContext { } export type ToolRuntimeContext = + | JuniorToolRuntimeContext | LocalToolRuntimeContext | SlackToolRuntimeContext | WebToolRuntimeContext; diff --git a/packages/junior/src/db/schema/destinations.ts b/packages/junior/src/db/schema/destinations.ts index 42210dca1..fe2b7d1bb 100644 --- a/packages/junior/src/db/schema/destinations.ts +++ b/packages/junior/src/db/schema/destinations.ts @@ -4,6 +4,7 @@ import { timestamptz } from "./timestamps"; export const juniorDestinationKindSchema = z.enum([ "channel", + "conversation", "dm", "group", "local_conversation", diff --git a/packages/junior/src/handlers/mcp-oauth-callback.ts b/packages/junior/src/handlers/mcp-oauth-callback.ts index 13f77515a..b1f61d26b 100644 --- a/packages/junior/src/handlers/mcp-oauth-callback.ts +++ b/packages/junior/src/handlers/mcp-oauth-callback.ts @@ -70,7 +70,7 @@ import type { WaitUntilFn } from "@/handlers/types"; import { createSlackResumeActor, type Actor } from "@/chat/actor"; import { requireSlackDestination } from "@/chat/destination"; import { relayLocalOAuthCallback } from "@/chat/local/oauth-relay"; -import { deleteWebAuthorization } from "@/chat/api-turns/authorization"; +import { deleteApiAuthorization } from "@/chat/api-turns/authorization"; function callbackPages(botName: string) { return { @@ -109,7 +109,7 @@ function callbackPages(botName: string) { interface McpOAuthCallbackOptions { agentRunner: AgentRunner; - /** Queue used to wake parked web turns after authorization completes. */ + /** Queue used to wake paused API Turns after authorization completes. */ conversationWorkQueue?: ConversationWorkQueue; } @@ -512,6 +512,7 @@ function mcpConversationId( ): string | undefined { if ( authSession.destination?.platform === "local" || + authSession.source?.platform === "junior" || authSession.source?.platform === "web" ) { return authSession.conversationId; @@ -631,7 +632,11 @@ export async function GET( }); } - if (authSession.source?.platform === "web" && authSession.destination) { + if ( + (authSession.source?.platform === "junior" || + authSession.source?.platform === "web") && + authSession.destination + ) { waitUntil(async () => { const turn = await getTurnRecord( authSession.conversationId, @@ -639,13 +644,16 @@ export async function GET( ); // Always clear the dashboard prompt. A superseded/abandoned turn must // not leave a stale connect banner after OAuth completes late. - await deleteWebAuthorization({ + await deleteApiAuthorization({ actorId: authSession.userId, conversationId: authSession.conversationId, }); if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") { return; } + const routing = await resolveTurnSessionRouting({ + conversationId: authSession.conversationId, + }); await recordAuthorizationCompleted({ conversationId: authSession.conversationId, kind: "mcp", @@ -659,7 +667,7 @@ export async function GET( await wakePausedTurn( { conversationId: authSession.conversationId, - destination: authSession.destination!, + destination: routing.destination, turnId: authSession.sessionId, expectedVersion: turn.version, }, @@ -683,6 +691,7 @@ export async function GET( // Only the CLI path should get the local-client success copy. local: authSession.destination?.platform === "local" && + authSession.source?.platform !== "junior" && authSession.source?.platform !== "web", }); } catch (callbackError) { diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 6a36d0b8e..7872b4b9f 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -79,12 +79,12 @@ import { getSqlExecutor } from "@/chat/db"; import { upsertIdentity, upsertLinkedIdentity } from "@/chat/identities/sql"; import { lookupSlackUserProfile } from "@/chat/slack/users"; import { parseSlackUserId } from "@/chat/slack/ids"; -import { deleteWebAuthorization } from "@/chat/api-turns/authorization"; +import { deleteApiAuthorization } from "@/chat/api-turns/authorization"; import { botConfig } from "@/chat/config"; interface OAuthCallbackOptions { agentRunner: AgentRunner; - /** Queue used to wake parked web turns after authorization completes. */ + /** Queue used to wake paused API Turns after authorization completes. */ conversationWorkQueue?: ConversationWorkQueue; } @@ -761,6 +761,7 @@ export async function GET( if ( stored.destination?.platform !== "local" && + stored.source?.platform !== "junior" && stored.source?.platform !== "web" ) { waitUntil(async () => { @@ -776,8 +777,9 @@ export async function GET( }); } - const resumesWebTurn = Boolean( - stored.source?.platform === "web" && + const resumesApiTurn = Boolean( + (stored.source?.platform === "junior" || + stored.source?.platform === "web") && stored.destination && stored.resumeConversationId && stored.resumeSessionId, @@ -787,7 +789,7 @@ export async function GET( stored.resumeConversationId && stored.resumeSessionId, ); - if (resumesWebTurn) { + if (resumesApiTurn) { waitUntil(async () => { const turn = await getTurnRecord( stored.resumeConversationId!, @@ -795,13 +797,16 @@ export async function GET( ); // Always clear the dashboard prompt. A superseded/abandoned turn must // not leave a stale connect banner after OAuth completes late. - await deleteWebAuthorization({ + await deleteApiAuthorization({ actorId: stored.userId, conversationId: stored.resumeConversationId!, }); if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") { return; } + const routing = await resolveTurnSessionRouting({ + conversationId: stored.resumeConversationId!, + }); await recordAuthorizationCompleted({ conversationId: stored.resumeConversationId!, kind: "plugin", @@ -815,7 +820,7 @@ export async function GET( await wakePausedTurn( { conversationId: stored.resumeConversationId!, - destination: stored.destination!, + destination: routing.destination, turnId: stored.resumeSessionId!, expectedVersion: turn.version, }, @@ -862,15 +867,15 @@ export async function GET( const botName = botConfig.userName; const statusMessage = - stored.destination?.platform === "local" && !resumesWebTurn + stored.destination?.platform === "local" && !resumesApiTurn ? `Your request is continuing in the local ${botName} client.` - : resumesWebTurn + : resumesApiTurn ? `Your request is continuing in ${botName}.` : resumesAgentTurn ? "Your request is being processed in Slack." : `Your ${providerLabel} account is connected.`; const footerMessage = - stored.destination?.platform === "local" || resumesWebTurn + stored.destination?.platform === "local" || resumesApiTurn ? `You can close this tab and return to ${botName}.` : "You can close this tab and return to Slack."; return htmlCallbackResponse( diff --git a/packages/junior/tests/fixtures/acp-http.ts b/packages/junior/tests/fixtures/acp-http.ts index 17eb09359..9f003d67e 100644 --- a/packages/junior/tests/fixtures/acp-http.ts +++ b/packages/junior/tests/fixtures/acp-http.ts @@ -6,7 +6,7 @@ import { vi } from "vitest"; import { completeAcpAuthorization } from "@/api/acp/auth"; import { createConversationWork } from "@/chat/app/conversation-work"; import { resolveViewerUser } from "@/chat/plugins/viewer"; -import type { ConversationWorkWebHarness } from "./api-turn"; +import type { ConversationApiHarness } from "./api-turn"; import { createSlackAdapterFixture } from "./conversation-work"; export const ACP_TEST_URL = "http://junior.test/api/acp"; @@ -79,7 +79,7 @@ export function appFetch(...apps: Hono[]): typeof globalThis.fetch { /** Build another app-scoped Conversation worker over the shared test stores. */ export function createIndependentConversationWork( - harness: ConversationWorkWebHarness, + harness: ConversationApiHarness, state: StateAdapter = harness.state, ) { return createConversationWork({ diff --git a/packages/junior/tests/fixtures/api-turn.ts b/packages/junior/tests/fixtures/api-turn.ts index cefd5f6c9..bd010470d 100644 --- a/packages/junior/tests/fixtures/api-turn.ts +++ b/packages/junior/tests/fixtures/api-turn.ts @@ -1,18 +1,16 @@ import type { StateAdapter } from "chat"; import type { StreamFn } from "@earendil-works/pi-agent-core"; -import type { Destination } from "@sentry/junior-plugin-api"; +import type { Destination, JuniorActor } from "@sentry/junior-plugin-api"; import { Hono } from "hono"; import { createJuniorApi, type JuniorApiVariables } from "@/api"; import { conversationPendingMessagesReportSchema, type ConversationPendingMessagesReport, } from "@/api/schema"; -import type { WebActor } from "@/chat/actor"; import { executeAgentRun } from "@/chat/agent"; import { appendAndEnqueueApiConversationMessage, createAndEnqueueApiConversation, - webActorFromEmail, } from "@/chat/api-turns/work"; import { createConversationWork, @@ -37,15 +35,14 @@ import { } from "./conversation-work"; import { testViewer } from "./user"; -/** Default verified dashboard viewer for Conversation API tests. */ +/** Default verified viewer for Conversation API tests. */ const API_TURN_TEST_EMAIL = "alice@example.com"; export const apiTurnTestActor = { - platform: "web" as const, - userId: webActorFromEmail(API_TURN_TEST_EMAIL).userId, + platform: "junior" as const, + userId: API_TURN_TEST_EMAIL, email: API_TURN_TEST_EMAIL, fullName: "Alice Example", - userName: "alice", -} as const satisfies WebActor; +} as const satisfies JuniorActor; export type ApiTurnWorkFixture = { actor: typeof apiTurnTestActor; @@ -93,7 +90,7 @@ export function emptyApiTurnAttempt(args: { }; } -export type ConversationWorkWebHarness = { +export type ConversationApiHarness = { actor: typeof apiTurnTestActor; agentRuns: AgentRun[]; agentRunner: AgentRunner; @@ -102,12 +99,12 @@ export type ConversationWorkWebHarness = { queue: ConversationWorkQueueTestAdapter; state: StateAdapter; setModelStream: (next: StreamFn) => void; - /** Create a new web root conversation and enqueue the first message. */ + /** Create a new Conversation API root and enqueue the first message. */ start: (args: { idempotencyKey: string; message: string; }) => Promise<{ conversationId: string; messageId: string }>; - /** Append one web follow-up to an existing conversation. */ + /** Append one API follow-up to an existing Conversation. */ continue: (args: { conversationId: string; idempotencyKey: string; @@ -127,9 +124,9 @@ export type ConversationWorkWebHarness = { * Compose the production Conversation API, queue, and agent path. * Fake only model generation at the agent Run boundary. */ -export async function createConversationWorkWebHarness( +export async function createConversationApiHarness( streamFn: StreamFn = streamReplies("Conversation API request complete."), -): Promise { +): Promise { const conversationStore = getConversationStore(); const queue = createConversationWorkQueueTestAdapter(); const state = getStateAdapter(); diff --git a/packages/junior/tests/fixtures/mcp-auth-orchestration.ts b/packages/junior/tests/fixtures/mcp-auth-orchestration.ts index 2dcd272a5..250b63d1b 100644 --- a/packages/junior/tests/fixtures/mcp-auth-orchestration.ts +++ b/packages/junior/tests/fixtures/mcp-auth-orchestration.ts @@ -6,7 +6,7 @@ import { } from "@/chat/mcp/auth-store"; import { listTurnSummaries } from "@/chat/task-execution/checkpoint"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; -import type { ConversationWorkWebHarness } from "./api-turn"; +import type { ConversationApiHarness } from "./api-turn"; import { CONVERSATION_ID, loadConversationState, @@ -89,7 +89,7 @@ export function streamOpenMcpSearchAndCall(replyAfterCall: string) { /** * Complete the latest MCP OAuth session for one actor through the real callback. * - * Pass `conversationWorkQueue` for web wakes that must hit the test queue. + * Pass `conversationWorkQueue` for API wakes that must hit the test queue. */ export async function completeLatestMcpAuth(args: { userId: string; @@ -116,8 +116,8 @@ export async function completeLatestMcpAuth(args: { /** * Assert MCP auth is parked for one actor. * - * Shared core: pendingAuth + paused turn record. Callers pass `delivery` for - * the surface-specific prompt (Slack ephemeral link, web pending-messages, …). + * The common check covers pending authorization and the paused Turn. Each + * caller uses `delivery` to check where its connect prompt appears. */ export async function expectMcpAuthParked(args: { actorId: string; @@ -164,9 +164,9 @@ export async function expectSlackMcpAuthParked(args: { }); } -/** Web delivery: participant pending-messages exposes a connect prompt. */ -export async function expectWebMcpAuthParked(args: { - harness: ConversationWorkWebHarness; +/** API delivery: participant pending messages expose a connect prompt. */ +export async function expectApiMcpAuthParked(args: { + harness: ConversationApiHarness; conversationId: string; provider?: string; }): Promise { diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts index 3466e3548..919ee8c18 100644 --- a/packages/junior/tests/integration/acp-http-recovery.test.ts +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -12,7 +12,7 @@ import { processConversationQueueMessage } from "@/chat/task-execution/vercel-ca import { recordConversationExecution } from "@/chat/task-execution/state"; import { closeApiTurnWorkFixture, - createConversationWorkWebHarness, + createConversationApiHarness, } from "../fixtures/api-turn"; import { streamMcpSearch } from "../fixtures/mcp-auth-orchestration"; import { @@ -54,7 +54,7 @@ describe("remote ACP recovery", () => { vi.stubEnv("JUNIOR_BASE_URL", ""); vi.stubEnv("VERCEL_PROJECT_PRODUCTION_URL", "junior.example.com"); vi.stubEnv("VERCEL_URL", "preview.example.com"); - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, dashboard: { @@ -163,7 +163,7 @@ describe("remote ACP recovery", () => { vi.stubEnv("JUNIOR_BASE_URL", ""); vi.stubEnv("VERCEL_PROJECT_PRODUCTION_URL", ""); vi.stubEnv("VERCEL_URL", ""); - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, dashboard: { authRequired: false }, @@ -228,7 +228,7 @@ describe("remote ACP recovery", () => { }); it("rejects a second authenticate request without replacing the pending sign-in", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); @@ -322,7 +322,7 @@ describe("remote ACP recovery", () => { it("rejects a second active prompt from another app instance", async () => { const modelStarted = deferred(); const releaseModel = deferred(); - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( createModelStream([ { type: "text", @@ -410,7 +410,7 @@ describe("remote ACP recovery", () => { SLACK_BOT_TOKEN: "xoxb-test-token", }; pluginApp = await createPluginAppFixture([EVAL_MCP_PLUGIN_ROOT]); - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamMcpSearch("Auth-paused Turn must not reply."), ); const app = await createApp({ @@ -489,7 +489,7 @@ describe("remote ACP recovery", () => { }, 20_000); it("cancels a yielded Turn from another app instance", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( createModelStream([ { type: "toolCall", name: "systemTime", arguments: {} }, { type: "text", text: "Cancelled resume must not reply." }, @@ -552,7 +552,7 @@ describe("remote ACP recovery", () => { it("holds terminal output until acknowledgement, then accepts a follow-up", async () => { const terminalPersisted = deferred(); const releaseWorker = deferred(); - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamReplies("Follow-up complete."), ); const run = harness.conversationWork.run; @@ -741,7 +741,7 @@ describe("remote ACP recovery", () => { }, 20_000); it("returns a terminal after failed cleanup and during a later Turn", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamReplies("First Turn complete."), ); const conversations = createAcpConversations({ @@ -816,7 +816,7 @@ describe("remote ACP recovery", () => { }); it("recovers prompt admission after the first queue send fails", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamReplies("Recovered queue reply."), ); const app = await createApp({ @@ -882,7 +882,7 @@ describe("remote ACP recovery", () => { }, 20_000); it("rejects a prompt before admission when its output stream is full", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); diff --git a/packages/junior/tests/integration/acp-http.test.ts b/packages/junior/tests/integration/acp-http.test.ts index 212fa3658..d58ae6cf4 100644 --- a/packages/junior/tests/integration/acp-http.test.ts +++ b/packages/junior/tests/integration/acp-http.test.ts @@ -1,12 +1,13 @@ import * as acp from "@agentclientprotocol/sdk"; import type { StateAdapter } from "chat"; +import { createJuniorSource } from "@sentry/junior-plugin-api"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; import { getConversationEventStore } from "@/chat/db"; import { createPersonalToken } from "@/personal-tokens/store"; import { closeApiTurnWorkFixture, - createConversationWorkWebHarness, + createConversationApiHarness, } from "../fixtures/api-turn"; import { ACP_TEST_URL as ACP_URL, @@ -31,7 +32,7 @@ describe("remote ACP HTTP", () => { }); it("mounts the endpoint without extra app config", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); @@ -45,7 +46,7 @@ describe("remote ACP HTTP", () => { }); it("stays mounted without dashboard authentication", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, dashboard: { disabled: true }, @@ -60,7 +61,7 @@ describe("remote ACP HTTP", () => { }); it("initializes with isolated cookies and rejects a valid personal token", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); @@ -109,7 +110,7 @@ describe("remote ACP HTTP", () => { }); it("validates JSON-RPC envelopes and initialization", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); @@ -171,7 +172,7 @@ describe("remote ACP HTTP", () => { }); it("requires ACP authentication before session methods", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); @@ -208,7 +209,7 @@ describe("remote ACP HTTP", () => { }); it("runs, reloads, and protects a private Conversation across app instances", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamReplies("First ACP reply."), ); const app = await createApp({ @@ -281,7 +282,7 @@ describe("remote ACP HTTP", () => { harness.conversationStore.get({ conversationId: sessionId }), ).resolves.toMatchObject({ conversationId: sessionId, - source: "web", + source: "api", visibility: "private", }); await vi.waitFor(() => { @@ -301,7 +302,8 @@ describe("remote ACP HTTP", () => { expect(harness.agentRuns).toHaveLength(1); expect(harness.agentRuns[0]).toMatchObject({ publishExternally: false, - source: { platform: "web", visibility: "private" }, + destination: { platform: "junior", conversationId: sessionId }, + source: createJuniorSource(sessionId, "private"), }); await expect(harness.historyTexts(sessionId)).resolves.toEqual([ expectedFirstPromptText, @@ -438,7 +440,7 @@ describe("remote ACP HTTP", () => { }, 20_000); it("deduplicates exact retries without colliding payloads or id types", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamReplies("Typed id reply."), ); const app = await createApp({ @@ -530,7 +532,7 @@ describe("remote ACP HTTP", () => { }, 20_000); it("coordinates an SSE stream across handoff and request abort", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); @@ -577,7 +579,7 @@ describe("remote ACP HTTP", () => { }); it("terminates an SSE stream after it loses its shared lease", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const state = new Proxy(harness.state, { get(target, property) { if (property === "extendLock") { @@ -632,7 +634,7 @@ describe("remote ACP HTTP", () => { }); it("finishes durable work after the ACP connection closes", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( streamReplies("Completed after disconnect."), ); const app = await createApp({ @@ -757,7 +759,7 @@ describe("remote ACP HTTP", () => { it("cancels the active Turn and accepts a later prompt", async () => { const modelStarted = deferred(); const releaseModel = deferred(); - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( createModelStream([ { type: "text", @@ -870,7 +872,7 @@ describe("remote ACP HTTP", () => { }, 20_000); it("maps one durable failed Turn to a protocol error", async () => { - const harness = await createConversationWorkWebHarness( + const harness = await createConversationApiHarness( createModelStream([{ type: "error", errorMessage: "model unavailable" }]), ); const app = await createApp({ @@ -928,7 +930,7 @@ describe("remote ACP HTTP", () => { }, 20_000); it("rejects unsupported MCP and prompt content at the protocol boundary", async () => { - const harness = await createConversationWorkWebHarness(); + const harness = await createConversationApiHarness(); const app = await createApp({ conversationWork: harness.conversationWork, }); diff --git a/packages/junior/tests/integration/api-turn-work.test.ts b/packages/junior/tests/integration/api-turn-work.test.ts index 9ae7d812d..0fcdd48b6 100644 --- a/packages/junior/tests/integration/api-turn-work.test.ts +++ b/packages/junior/tests/integration/api-turn-work.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createWebSource } from "@sentry/junior-plugin-api"; +import { createJuniorSource } from "@sentry/junior-plugin-api"; import { createApiTurnCancellation } from "@/chat/api-turns/cancellation"; import { appendAndEnqueueApiConversationMessage, - apiTurnIdForMessage, buildApiTurnInboundMessage, createAndEnqueueApiConversation, - createApiConversationId, createApiTurnWorker, resolveApiTurnWork, routeApiTurnWork, } from "@/chat/api-turns/work"; +import { + apiTurnIdForMessage, + createApiConversationId, +} from "@/chat/api-turns/ids"; import type { AgentRun } from "@/chat/agent/types"; import { getConversationEventStore } from "@/chat/db"; import { processConversationQueueMessage } from "@/chat/task-execution/vercel-callback"; @@ -26,6 +28,9 @@ import { import { createModelAgentRunnerForRun } from "../fixtures/agent-runner"; import { createModelStream } from "../fixtures/model-stream"; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + describe("Conversation API work", () => { afterEach(async () => { await closeApiTurnWorkFixture(); @@ -36,7 +41,7 @@ describe("Conversation API work", () => { const { actor, conversationStore, queue, state } = await createApiTurnWorkFixture(); const expectedConversationId = createApiConversationId({ - actorEmail: actor.email, + actorId: actor.userId, idempotencyKey: "create-1", }); const accepted = await createAndEnqueueApiConversation( @@ -70,7 +75,7 @@ describe("Conversation API work", () => { await expect( conversationStore.get({ conversationId: accepted.conversationId }), ).resolves.toMatchObject({ - sessionSource: createWebSource(accepted.conversationId, "private"), + sessionSource: createJuniorSource(accepted.conversationId, "private"), visibility: "private", }); @@ -102,16 +107,18 @@ describe("Conversation API work", () => { { conversationStore, queue, state }, ); expect(accepted.status).toBe("accepted"); - expect(accepted.conversationId.startsWith("local:web:")).toBe(true); + expect(accepted.conversationId).toMatch(UUID_PATTERN); + expect(accepted.messageId).toMatch(UUID_PATTERN); + expect(apiTurnIdForMessage(accepted.messageId)).toMatch(UUID_PATTERN); await expect( conversationStore.get({ conversationId: accepted.conversationId }), ).resolves.toMatchObject({ - source: "web", - sessionSource: createWebSource(accepted.conversationId, "public"), + source: "api", + sessionSource: createJuniorSource(accepted.conversationId, "public"), visibility: "public", destination: { - platform: "local", + platform: "junior", conversationId: accepted.conversationId, }, actor: { @@ -128,7 +135,11 @@ describe("Conversation API work", () => { }); expect(inbound).toMatchObject({ publishExternally: false, - source: "web", + destination: { + platform: "junior", + conversationId: accepted.conversationId, + }, + source: "api", }); const agentRuns: AgentRun[] = []; @@ -162,8 +173,17 @@ describe("Conversation API work", () => { expect(agentRuns[0]).toEqual( expect.objectContaining({ publishExternally: false, - source: createWebSource(accepted.conversationId, "public"), - actor: expect.objectContaining({ platform: "web" }), + destination: { + platform: "junior", + conversationId: accepted.conversationId, + }, + source: createJuniorSource(accepted.conversationId, "public"), + actor: expect.objectContaining({ + platform: "junior", + userId: actor.userId, + }), + runId: expect.stringMatching(UUID_PATTERN), + turnId: expect.stringMatching(UUID_PATTERN), }), ); @@ -184,14 +204,25 @@ describe("Conversation API work", () => { expect.objectContaining({ role: "user", text: "Start a dashboard turn.", - meta: expect.objectContaining({ source: "web" }), + meta: expect.objectContaining({ + source: "api", + turnId: apiTurnIdForMessage(accepted.messageId), + }), }), expect.objectContaining({ role: "assistant", text: "Stored only in Junior.", - meta: expect.objectContaining({ source: "web" }), + meta: expect.objectContaining({ + source: "api", + turnId: apiTurnIdForMessage(accepted.messageId), + }), }), ]); + expect( + messages.map((event) => + event.data.type === "message" ? event.data.messageId : undefined, + ), + ).toEqual([accepted.messageId, expect.stringMatching(UUID_PATTERN)]); const agentReply = history.findIndex( (event) => event.data.type === "assistant_message", ); @@ -255,7 +286,7 @@ describe("Conversation API work", () => { await expect( conversationStore.get({ conversationId: accepted.conversationId }), ).resolves.toMatchObject({ - sessionSource: createWebSource(accepted.conversationId, "private"), + sessionSource: createJuniorSource(accepted.conversationId, "private"), visibility: "private", }); @@ -287,7 +318,7 @@ describe("Conversation API work", () => { ).resolves.toMatchObject({ status: "completed" }); expect(agentRuns).toHaveLength(1); expect(agentRuns[0]?.source).toEqual( - createWebSource(accepted.conversationId, "private"), + createJuniorSource(accepted.conversationId, "private"), ); }); @@ -302,17 +333,14 @@ describe("Conversation API work", () => { }, { conversationStore, queue, state }, ); - const destination = { - platform: "local" as const, - conversationId: accepted.conversationId, - }; const inbound = buildApiTurnInboundMessage({ actor, conversationId: accepted.conversationId, - destination, message: "Cancel before this Turn starts.", messageId: accepted.messageId, }); + const destination = inbound.destination; + if (!destination) throw new Error("Expected a Conversation destination"); const cancellation = createApiTurnCancellation(); const signal = cancellation.begin(accepted.conversationId); if (!signal) throw new Error("Expected an active Turn signal"); @@ -364,7 +392,7 @@ describe("Conversation API work", () => { ); const turnId = apiTurnIdForMessage(accepted.messageId); const destination = { - platform: "local" as const, + platform: "junior" as const, conversationId: accepted.conversationId, }; await saveTurnCheckpoint({ @@ -382,7 +410,7 @@ describe("Conversation API work", () => { ], destination, publishExternally: false, - source: createWebSource(accepted.conversationId), + source: createJuniorSource(accepted.conversationId), actor, surface: "api", }); @@ -509,7 +537,7 @@ describe("Conversation API work", () => { expect(inbound).toMatchObject({ destination: slackDestination, publishExternally: false, - source: "web", + source: "api", }); const agentRuns: AgentRun[] = []; @@ -540,9 +568,12 @@ describe("Conversation API work", () => { expect(agentRuns).toHaveLength(1); expect(agentRuns[0]).toEqual( expect.objectContaining({ - destination: expect.objectContaining({ platform: "slack" }), + destination: { + platform: "junior", + conversationId, + }, publishExternally: false, - source: expect.objectContaining({ platform: "web" }), + source: expect.objectContaining({ platform: "junior" }), }), ); diff --git a/packages/junior/tests/integration/api/conversations/pending-messages.test.ts b/packages/junior/tests/integration/api/conversations/pending-messages.test.ts index 0a546cf87..a9117e8ef 100644 --- a/packages/junior/tests/integration/api/conversations/pending-messages.test.ts +++ b/packages/junior/tests/integration/api/conversations/pending-messages.test.ts @@ -9,6 +9,8 @@ import { appendAndEnqueueApiConversationMessage, createAndEnqueueApiConversation, } from "@/chat/api-turns/work"; +import { createApiAuthorization } from "@/chat/api-turns/authorization"; +import { legacyApiActorId } from "@/chat/api-turns/legacy-actor"; import { closeDb, getConversationStore } from "@/chat/db"; import { appendInboundMessage } from "@/chat/task-execution/store"; import { @@ -25,7 +27,7 @@ describe("conversation pending messages API", () => { await closeDb(); }); - it("returns accepted web mailbox rows before history commit", async () => { + it("returns accepted API input and authorization stored under the old Actor id", async () => { const { actor, conversationStore, queue, state } = await createApiTurnWorkFixture(); const accepted = await createAndEnqueueApiConversation( @@ -37,7 +39,12 @@ describe("conversation pending messages API", () => { { conversationStore, queue, state }, ); - const app = createJuniorApi(); + const app = new Hono<{ Variables: JuniorApiVariables }>(); + app.use("*", async (context, next) => { + context.set("viewer", testViewer(actor.email)); + await next(); + }); + app.route("/", createJuniorApi()); const response = await app.request( `http://localhost/api/conversations/${encodeURIComponent(accepted.conversationId)}/pending-messages`, ); @@ -59,10 +66,31 @@ describe("conversation pending messages API", () => { messageId: accepted.messageId, receivedAt: expect.any(String), role: "user", - source: "web", + source: "api", text: "dashboard follow-up", }, ]); + + await createApiAuthorization({ + actorId: legacyApiActorId(actor.email), + conversationId: accepted.conversationId, + }).deliver({ + authorizationUrl: "https://example.com/connect", + completionText: "Connected", + label: "Example", + }); + const legacyAuthorizationResponse = await app.request( + `http://localhost/api/conversations/${encodeURIComponent(accepted.conversationId)}/pending-messages`, + ); + const reportWithLegacyAuthorization = + conversationPendingMessagesReportSchema.parse( + await legacyAuthorizationResponse.json(), + ); + expect(reportWithLegacyAuthorization.authorization).toEqual({ + authorizationUrl: "https://example.com/connect", + completionText: "Connected", + label: "Example", + }); }); it("returns accepted slack interrupt mailbox rows before history commit", async () => { @@ -212,7 +240,7 @@ describe("conversation pending messages API", () => { receivedAt: expect.any(String), redacted: true, role: "user", - source: "web", + source: "api", }, ]); }); @@ -226,7 +254,7 @@ describe("conversation pending messages API", () => { expect(response.status).toBe(404); }); - it("keeps append-only web continues visible in the mailbox snapshot", async () => { + it("keeps appended API input visible in the mailbox snapshot", async () => { const { actor, conversationStore, queue, state } = await createApiTurnWorkFixture(); const created = await createAndEnqueueApiConversation( diff --git a/packages/junior/tests/integration/web-auth-orchestration.test.ts b/packages/junior/tests/integration/conversation-api-auth.test.ts similarity index 67% rename from packages/junior/tests/integration/web-auth-orchestration.test.ts rename to packages/junior/tests/integration/conversation-api-auth.test.ts index 22a27c419..4290a9306 100644 --- a/packages/junior/tests/integration/web-auth-orchestration.test.ts +++ b/packages/junior/tests/integration/conversation-api-auth.test.ts @@ -1,19 +1,25 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stopApiConversationTurn } from "@/chat/api-turns/stop"; -import { apiTurnIdForMessage } from "@/chat/api-turns/work"; -import { getLatestMcpAuthSessionForUserProvider } from "@/chat/mcp/auth-store"; +import { apiTurnIdForMessage } from "@/chat/api-turns/ids"; +import { legacyApiActorId } from "@/chat/api-turns/legacy-actor"; +import { + deleteMcpStoredOAuthCredentials, + getLatestMcpAuthSessionForUserProvider, + getMcpStoredOAuthCredentials, + putMcpStoredOAuthCredentials, +} from "@/chat/mcp/auth-store"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { listTurnSummaries } from "@/chat/task-execution/checkpoint"; import { closeApiTurnWorkFixture, - createConversationWorkWebHarness, + createConversationApiHarness, } from "../fixtures/api-turn"; import { completeLatestMcpAuth, expectMcpAuthCleared, expectMcpAuthCredentialsStored, - expectWebMcpAuthParked, + expectApiMcpAuthParked, streamMcpSearch, streamMcpSearchAndCall, } from "../fixtures/mcp-auth-orchestration"; @@ -29,12 +35,12 @@ import { import { EVAL_MCP_AUTH_PROVIDER } from "../msw/handlers/eval-mcp-auth"; /** - * Web interactive auth through the durable queue. + * Conversation API authorization through the durable queue. * - * Common dashboard behaviors only. Fake model stream. Real web ingress, - * worker, agent, pending-messages API, and MCP OAuth callback fixtures. - * Matches the Slack MCP auth orchestration approach so park / resume / - * supersede bugs stay covered at the product boundary. + * Fake only the model stream. Use real API ingress, worker, agent, + * pending-messages API, and MCP OAuth callback fixtures. Match the Slack MCP + * authorization approach so park, resume, and supersede bugs stay covered at + * the product boundary. */ const ORIGINAL_ENV = { ...process.env }; @@ -43,7 +49,7 @@ const EVAL_MCP_PLUGIN_ROOT = path.resolve( "../fixtures/plugins/eval-auth", ); -describe("web auth orchestration", () => { +describe("Conversation API authorization", () => { let pluginApp: PluginAppFixture | undefined; beforeEach(async () => { @@ -65,18 +71,36 @@ describe("web auth orchestration", () => { process.env = { ...ORIGINAL_ENV }; }); - it("parks a Turn from the Conversation API for MCP auth, shows the prompt, and resumes after OAuth", async () => { - const q = await createConversationWorkWebHarness( + it("parks and resumes authorization and keeps credentials from the old Actor id", async () => { + const q = await createConversationApiHarness( streamMcpSearchAndCall("Eval Auth tool completed."), ); - const started = await q.start({ - idempotencyKey: "web-auth-park-resume-1", + const conversationId = "slack:CAPI123:1787718000.000001"; + await q.conversationStore.recordActivity({ + actor: { + email: q.actor.email, + platform: "slack", + slackUserId: "UAPIROOT", + teamId: "TAPIROOT", + }, + conversationId, + destination: { + channelId: "CAPI123", + platform: "slack", + teamId: "TAPIROOT", + }, + source: "slack", + visibility: "private", + }); + await q.continue({ + conversationId, + idempotencyKey: "api-auth-park-resume-1", message: "use eval-auth and confirm the connection", }); await q.drain(); - await expectWebMcpAuthParked({ + await expectApiMcpAuthParked({ harness: q, - conversationId: started.conversationId, + conversationId, }); await completeLatestMcpAuth({ @@ -86,32 +110,73 @@ describe("web auth orchestration", () => { }); await q.drain(); - await expectMcpAuthCleared(started.conversationId); + await expectMcpAuthCleared(conversationId); await expectMcpAuthCredentialsStored(q.actor.userId); - await expect( - q.pendingMessages(started.conversationId), - ).resolves.not.toHaveProperty("authorization"); - const history = await q.historyTexts(started.conversationId); + await expect(q.pendingMessages(conversationId)).resolves.not.toHaveProperty( + "authorization", + ); + const history = await q.historyTexts(conversationId); expect( history.some((text) => text.includes("Eval Auth tool completed")), ).toBe(true); - await expect(listTurnSummaries(started.conversationId)).resolves.toEqual( + expect(q.agentRuns).toHaveLength(2); + expect(q.agentRuns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actor: expect.objectContaining({ + platform: "junior", + userId: q.actor.userId, + }), + destination: { conversationId, platform: "junior" }, + }), + ]), + ); + await expect(listTurnSummaries(conversationId)).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ state: "completed", surface: "api" }), ]), ); + + const credentials = await getMcpStoredOAuthCredentials( + q.actor.userId, + EVAL_MCP_AUTH_PROVIDER, + ); + expect(credentials).toBeDefined(); + await putMcpStoredOAuthCredentials( + legacyApiActorId(q.actor.email), + EVAL_MCP_AUTH_PROVIDER, + credentials!, + ); + await deleteMcpStoredOAuthCredentials( + q.actor.userId, + EVAL_MCP_AUTH_PROVIDER, + ); + + q.setModelStream(streamMcpSearch("Old Actor credentials still work.")); + await q.continue({ + conversationId, + idempotencyKey: "api-auth-legacy-credential", + message: "use eval-auth again", + }); + await q.drain(); + + await expectMcpAuthCredentialsStored(q.actor.userId); + expect(await q.historyTexts(conversationId)).toContain( + "Old Actor credentials still work.", + ); + expect(q.agentRuns).toHaveLength(3); }); it("supersedes an auth-parked Turn from the Conversation API and clears the prompt", async () => { - const q = await createConversationWorkWebHarness( + const q = await createConversationApiHarness( streamMcpSearch("Eval Auth is connected."), ); const started = await q.start({ - idempotencyKey: "web-auth-supersede-1", + idempotencyKey: "api-auth-supersede-1", message: "connect eval-auth first", }); await q.drain(); - await expectWebMcpAuthParked({ + await expectApiMcpAuthParked({ harness: q, conversationId: started.conversationId, }); @@ -120,7 +185,7 @@ describe("web auth orchestration", () => { q.setModelStream(streamScript("Answered without waiting for auth.")); const followUp = await q.continue({ conversationId: started.conversationId, - idempotencyKey: "web-auth-supersede-2", + idempotencyKey: "api-auth-supersede-2", message: "skip auth and answer this instead", }); await q.drain(); @@ -142,7 +207,7 @@ describe("web auth orchestration", () => { q.pendingMessages(started.conversationId), ).resolves.not.toHaveProperty("authorization"); // pendingAuth stays so a still-in-flight OAuth connect can store tokens; - // only the dashboard banner and parked turn are cleared/abandoned. + // only the connect prompt and parked Turn are cleared or abandoned. expect( (await loadConversationState(started.conversationId)).processing .pendingAuth, @@ -160,15 +225,15 @@ describe("web auth orchestration", () => { }); it("stops queued and auth-paused Turns from the Conversation API without process state", async () => { - const q = await createConversationWorkWebHarness( + const q = await createConversationApiHarness( streamMcpSearch("This response must not run."), ); const started = await q.start({ - idempotencyKey: "web-auth-stop-1", + idempotencyKey: "api-auth-stop-1", message: "connect eval-auth first", }); await q.drain(); - await expectWebMcpAuthParked({ + await expectApiMcpAuthParked({ harness: q, conversationId: started.conversationId, }); @@ -176,7 +241,7 @@ describe("web auth orchestration", () => { await q.continue({ conversationId: started.conversationId, - idempotencyKey: "web-auth-stop-2", + idempotencyKey: "api-auth-stop-2", message: "cancel this queued follow-up", }); await expect( @@ -227,16 +292,16 @@ describe("web auth orchestration", () => { expect(q.agentRuns).toHaveLength(1); }); - it("does not resume a superseded web auth turn after a late OAuth callback", async () => { - const q = await createConversationWorkWebHarness( + it("does not resume a superseded API Turn after a late OAuth callback", async () => { + const q = await createConversationApiHarness( streamMcpSearch("Eval Auth is connected."), ); const started = await q.start({ - idempotencyKey: "web-auth-late-oauth-1", + idempotencyKey: "api-auth-late-oauth-1", message: "connect eval-auth first", }); await q.drain(); - await expectWebMcpAuthParked({ + await expectApiMcpAuthParked({ harness: q, conversationId: started.conversationId, }); @@ -249,7 +314,7 @@ describe("web auth orchestration", () => { q.setModelStream(streamScript("Moved on without auth.")); await q.continue({ conversationId: started.conversationId, - idempotencyKey: "web-auth-late-oauth-2", + idempotencyKey: "api-auth-late-oauth-2", message: "never mind the provider", }); await q.drain(); diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index b1a060e3c..8a8189b46 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -681,7 +681,7 @@ describe("conversation report event projection", () => { expect(JSON.stringify(projected)).not.toContain("AAAA"); }); - it("projects dashboard source with visible message metadata", () => { + it("projects Conversation API source with visible message metadata", () => { const [projected] = projectConversationReportEventPage({ canExposePayload: true, events: [ @@ -693,7 +693,7 @@ describe("conversation report event projection", () => { meta: { eventType: "pull_request.merged", provider: "private-provider", - source: "web", + source: "api", }, }), ], @@ -703,7 +703,7 @@ describe("conversation report event projection", () => { type: "message", messageId: "event-1", role: "user", - source: "web", + source: "api", eventType: "pull_request.merged", text: "event details", });