diff --git a/.changeset/calm-spiders-receive.md b/.changeset/calm-spiders-receive.md new file mode 100644 index 000000000..3d8e98562 --- /dev/null +++ b/.changeset/calm-spiders-receive.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Keep receive-only channels discoverable and resolvable by schedules and cross-channel handoffs without requiring a placeholder HTTP route. diff --git a/docs/channels/custom.mdx b/docs/channels/custom.mdx index ecce8e1d7..9a7f32dc7 100644 --- a/docs/channels/custom.mdx +++ b/docs/channels/custom.mdx @@ -219,6 +219,27 @@ Semantics: - Calling `args.receive(...)` does not also start a session on the current channel. The inbound channel's response is whatever the route handler returns explicitly. - The first argument is the target channel module's default export. Import it directly from `agent/channels/.ts`. Identity is matched by reference. +### Receive-only channels + +A channel that is only a target for schedules or cross-channel hand-offs does not need an HTTP or WebSocket route. Declare an empty `routes` array and implement `receive`: + +```ts +import { defineChannel } from "eve/channels"; + +export default defineChannel({ + routes: [], + async receive(input, { send }) { + return await send(input.message, { + auth: input.auth, + continuationToken: input.target.workspaceId, + mode: "task", + }); + }, +}); +``` + +eve keeps the channel in discovery and makes it resolvable by `receive(channel, ...)`, but mounts no network route for it. + ## Channel metadata A channel can project a subset of its adapter state as metadata, available to instrumentation resolvers, dynamic tool resolvers, and dynamic skill or instruction resolvers. Define a `metadata(state)` function on the channel config: diff --git a/packages/eve/src/channel/schedule.test.ts b/packages/eve/src/channel/schedule.test.ts index f27bf0e2b..13e2d6738 100644 --- a/packages/eve/src/channel/schedule.test.ts +++ b/packages/eve/src/channel/schedule.test.ts @@ -12,6 +12,7 @@ import { import type { RunHandle, Runtime } from "#channel/types.js"; import { RuntimeNoActiveSessionError } from "#execution/runtime-errors.js"; import { slackChannel } from "#public/channels/slack/slackChannel.js"; +import { defineChannel } from "#public/definitions/channel.js"; import type { ResolvedChannelDefinition } from "#runtime/types.js"; function createMockRunHandle(): RunHandle { @@ -154,6 +155,50 @@ describe("ScheduleDispatcher", () => { expect(result.sessions).toHaveLength(0); }); + it("dispatches to a registered receive-only channel", async () => { + const runtime = createMockRuntime(); + const definition = defineChannel({ + routes: [], + async receive(input, { send }) { + return await send(input.message, { + auth: input.auth, + continuationToken: String(input.target.workspaceId), + mode: "task", + }); + }, + }) as CompiledChannel; + const resolved = { + adapter: definition.adapter, + definition, + logicalPath: "channels/learning.ts", + name: "learning", + receive: definition.receive, + sourceId: "channels/learning.ts", + sourceKind: "module", + } satisfies ResolvedChannelDefinition; + const dispatcher = new ScheduleDispatcher({ runtime, channels: [resolved] }); + + await dispatcher.trigger({ + scheduleId: "learn", + async run({ appAuth, receive }) { + await receive(definition, { + auth: appAuth, + message: "Study the latest outcomes.", + target: { workspaceId: "ws_123" }, + }); + }, + }); + + expect(runtime.run).toHaveBeenCalledWith( + expect.objectContaining({ + auth: SCHEDULE_APP_AUTH, + continuationToken: "learning:ws_123", + input: { message: "Study the latest outcomes." }, + mode: "task", + }), + ); + }); + it("throws when args.receive(channel) is called with an unregistered channel", async () => { const runtime = createMockRuntime(); const dispatcher = new ScheduleDispatcher({ runtime, channels: [] }); diff --git a/packages/eve/src/cli/commands/info.ts b/packages/eve/src/cli/commands/info.ts index f4241a9af..ba49a5f56 100644 --- a/packages/eve/src/cli/commands/info.ts +++ b/packages/eve/src/cli/commands/info.ts @@ -67,7 +67,14 @@ export function buildApplicationInfoJson(inspection: ApplicationInspection): App method: channel.method, urlPath: channel.urlPath, } - : { name: channel.name, kind: "disabled", method: null, urlPath: null }, + : channel.kind === "receive-only-channel" + ? { + name: channel.name, + kind: channel.adapterKind ?? null, + method: null, + urlPath: null, + } + : { name: channel.name, kind: "disabled", method: null, urlPath: null }, ), messaging: { create: messaging.createSessionRoutePath, diff --git a/packages/eve/src/client/agent-info-schema.ts b/packages/eve/src/client/agent-info-schema.ts index 67a461e8c..6c0f908eb 100644 --- a/packages/eve/src/client/agent-info-schema.ts +++ b/packages/eve/src/client/agent-info-schema.ts @@ -82,9 +82,9 @@ const subagent = entry.extend({ const channel = entry.extend({ adapterKind: z.string().optional(), - method: z.string(), + method: z.string().nullable(), origin: z.enum(["authored", "framework"]), - urlPath: z.string(), + urlPath: z.string().nullable(), }); const frameworkChannel = channel.extend({ diff --git a/packages/eve/src/compiler/manifest.ts b/packages/eve/src/compiler/manifest.ts index 461f3ac76..312c01085 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -41,12 +41,16 @@ export const ROOT_COMPILED_AGENT_NODE_ID = "__root__"; /** * Current compiled manifest schema version. */ -export const COMPILED_AGENT_MANIFEST_VERSION = 36; +export const COMPILED_AGENT_MANIFEST_VERSION = 37; /** * Compiled channel entry preserved in the compiled manifest. */ -export type CompiledChannelEntry = CompiledChannelDefinition | DisabledCompiledChannelEntry; +export type CompiledActiveChannelDefinition = + | CompiledChannelDefinition + | CompiledReceiveOnlyChannelDefinition; + +export type CompiledChannelEntry = CompiledActiveChannelDefinition | DisabledCompiledChannelEntry; /** * Active compiled channel entry — backed by an authored `Channel` module. @@ -77,6 +81,19 @@ export interface CompiledChannelDefinition { readonly cors?: NormalizedChannelCorsOptions; } +/** + * Active compiled channel entry with a `receive` hook and no network route. + */ +export interface CompiledReceiveOnlyChannelDefinition { + readonly kind: "receive-only-channel"; + readonly name: string; + readonly logicalPath: string; + readonly sourceId: string; + readonly sourceKind: "module"; + readonly exportName?: string; + readonly adapterKind?: string; +} + /** * Disabled compiled channel entry — marker that an authored file at this * slug exported `disableRoute()` to remove the matching framework default. @@ -337,6 +354,18 @@ const compiledChannelDefinitionSchema = z }) .strict(); +const compiledReceiveOnlyChannelDefinitionSchema = z + .object({ + kind: z.literal("receive-only-channel"), + name: z.string(), + logicalPath: z.string(), + sourceId: z.string(), + sourceKind: z.literal("module"), + exportName: z.string().optional(), + adapterKind: z.string().optional(), + }) + .strict(); + const disabledCompiledChannelEntrySchema = z .object({ kind: z.literal("disabled"), @@ -347,6 +376,7 @@ const disabledCompiledChannelEntrySchema = z const compiledChannelEntrySchema = z.union([ compiledChannelDefinitionSchema, + compiledReceiveOnlyChannelDefinitionSchema, disabledCompiledChannelEntrySchema, ]) as unknown as z.ZodType; diff --git a/packages/eve/src/compiler/normalize-channel.ts b/packages/eve/src/compiler/normalize-channel.ts index d35021c84..81f11b736 100644 --- a/packages/eve/src/compiler/normalize-channel.ts +++ b/packages/eve/src/compiler/normalize-channel.ts @@ -18,8 +18,10 @@ import { * * Authored channels are always `CompiledChannel` values (from * `defineChannel`). Each route in the channel's `routes` array becomes - * a separate compiled channel entry. The channel name is derived from - * the filesystem path; the URL path comes from the route's `path` field. + * a separate compiled channel entry. A channel with no routes and a + * `receive` hook becomes one receive-only entry. The channel name is + * derived from the filesystem path; routed entries take their URL path + * from the route's `path` field. */ export async function compileChannelDefinition( agentRoot: string, @@ -48,16 +50,27 @@ export async function compileChannelDefinition( `Expected the channel export "${source.exportName ?? "default"}" from "${source.logicalPath}" to match the public eve shape.`, ); - return definition.routes.map((route) => ({ - kind: "channel" as const, + const commonDefinition = { name: channelName, logicalPath: source.logicalPath, - method: route.method.toUpperCase() as ChannelRouteMethod, - urlPath: route.path, sourceId: source.sourceId, sourceKind: "module" as const, exportName: source.exportName, adapterKind: extractAdapterKind(definition.adapter), + }; + + if (definition.routes.length === 0 && definition.receive !== undefined) { + return { + kind: "receive-only-channel", + ...commonDefinition, + }; + } + + return definition.routes.map((route) => ({ + kind: "channel" as const, + ...commonDefinition, + method: route.method.toUpperCase() as ChannelRouteMethod, + urlPath: route.path, cors: definition.cors, })); } diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index aa2586e92..59ad261fc 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -8,7 +8,9 @@ import { } from "#discover/manifest.js"; import { classifyModelRouting } from "#internal/classify-model-routing.js"; import type { CompiledAgentDefinition } from "#compiler/manifest.js"; +import { collectModuleRefsForManifest } from "#compiler/module-map.js"; import { compileAgentManifest } from "#compiler/normalize-manifest.js"; +import { defineChannel } from "#public/definitions/channel.js"; import { experimental_workflow } from "#public/definitions/tool.js"; const mocks = vi.hoisted(() => ({ @@ -93,6 +95,47 @@ describe("compileAgentManifest", () => { expect(compiled.workflowTool).toEqual({ maxSubagents: 6 }); }); + + it("preserves receive-only channels in the manifest and module map", async () => { + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + channels: [createModuleSourceRef({ logicalPath: "channels/learning.ts" })], + }); + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.loadModuleBackedDefinition.mockResolvedValue( + defineChannel({ + routes: [], + async receive(input, { send }) { + return await send(input.message, { + auth: input.auth, + continuationToken: "learning", + mode: "task", + }); + }, + }), + ); + + const compiled = await compileAgentManifest(manifest); + + expect(compiled.channels).toEqual([ + { + adapterKind: "http", + kind: "receive-only-channel", + logicalPath: "channels/learning.ts", + name: "learning", + sourceId: "channels/learning.ts", + sourceKind: "module", + }, + ]); + expect(collectModuleRefsForManifest(compiled)).toContainEqual({ + exportName: undefined, + logicalPath: "channels/learning.ts", + sourceId: "channels/learning.ts", + sourceKind: "module", + }); + }); }); function createConfig( diff --git a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.test.ts b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.test.ts index 9ea759f03..52f9447f5 100644 --- a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.test.ts +++ b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + type CompiledReceiveOnlyChannelDefinition, type CompiledChannelDefinition, type CompiledConnectionDefinition, type CompiledScheduleDefinition, @@ -83,6 +84,20 @@ function makeChannel(input: { name: string; adapterKind?: string }): CompiledCha }; } +function makeReceiveOnlyChannel(input: { + name: string; + adapterKind?: string; +}): CompiledReceiveOnlyChannelDefinition { + return { + adapterKind: input.adapterKind, + kind: "receive-only-channel", + logicalPath: `channels/${input.name}.ts`, + name: input.name, + sourceId: `channels/${input.name}.ts`, + sourceKind: "module", + }; +} + function makeFlatSkill(name: string): CompiledSkillDefinition { return { description: `${name} description`, @@ -146,6 +161,7 @@ describe("buildVercelAgentSummary", () => { makeChannel({ name: "messages", adapterKind: "http" }), makeChannel({ name: "stripe", adapterKind: "stripe-webhook" }), makeChannel({ name: "mystery" }), + makeReceiveOnlyChannel({ name: "learning", adapterKind: "learning-worker" }), { kind: "disabled", logicalPath: "channels/disabled.ts", name: "disabled" }, ], config: { @@ -320,6 +336,15 @@ describe("buildVercelAgentSummary", () => { type: "unknown", urlPath: "/mystery", }, + { + adapterKind: "learning-worker", + logicalPath: "channels/learning.ts", + method: null, + name: "learning", + receiveOnly: true, + type: "unknown", + urlPath: null, + }, ]); expect(summary.sandbox).toBeNull(); diff --git a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts index 15eb93f98..21c81b285 100644 --- a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts +++ b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts @@ -2,8 +2,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type { + CompiledActiveChannelDefinition, CompiledAgentManifest, - CompiledChannelDefinition, CompiledChannelEntry, CompiledConnectionDefinition, CompiledInstructionsDefinition, @@ -100,8 +100,8 @@ export async function emitVercelAgentSummary(input: { return input.outputPath; } -function isActiveChannel(entry: CompiledChannelEntry): entry is CompiledChannelDefinition { - return entry.kind === "channel"; +function isActiveChannel(entry: CompiledChannelEntry): entry is CompiledActiveChannelDefinition { + return entry.kind !== "disabled"; } function toInstructionsEntry( @@ -158,20 +158,22 @@ function toConnectionEntry(connection: CompiledConnectionDefinition): VercelEveC return entry; } -function toChannelEntry(channel: CompiledChannelDefinition): VercelEveChannelEntry { +function toChannelEntry(channel: CompiledActiveChannelDefinition): VercelEveChannelEntry { const entry: VercelEveChannelEntry = { name: channel.name, - method: channel.method, - urlPath: channel.urlPath, + method: channel.kind === "channel" ? channel.method : null, + urlPath: channel.kind === "channel" ? channel.urlPath : null, type: normalizeChannelKindForDisplay(channel.adapterKind), logicalPath: channel.logicalPath, }; + const channelEntry: VercelEveChannelEntry = + channel.kind === "receive-only-channel" ? { ...entry, receiveOnly: true } : entry; if (channel.adapterKind !== undefined) { - return { ...entry, adapterKind: channel.adapterKind }; + return { ...channelEntry, adapterKind: channel.adapterKind }; } - return entry; + return channelEntry; } function toSubagentEntry(subagent: CompiledSubagentNode): VercelEveSubagentEntry { diff --git a/packages/eve/src/internal/nitro/host/channel-routes.test.ts b/packages/eve/src/internal/nitro/host/channel-routes.test.ts index 92e747326..92408c027 100644 --- a/packages/eve/src/internal/nitro/host/channel-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/channel-routes.test.ts @@ -1,9 +1,34 @@ import { describe, expect, it } from "vitest"; +import type { CompiledReceiveOnlyChannelDefinition } from "#compiler/manifest.js"; import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; -import { registerChannelVirtualHandlers } from "#internal/nitro/host/channel-routes.js"; +import { + computeChannelRouteRegistrations, + registerChannelVirtualHandlers, +} from "#internal/nitro/host/channel-routes.js"; describe("registerChannelVirtualHandlers", () => { + it("does not mount a route for a receive-only channel", () => { + const receiveOnlyChannel = { + adapterKind: "http", + kind: "receive-only-channel", + logicalPath: "channels/learning.ts", + name: "learning", + sourceId: "channels/learning.ts", + sourceKind: "module", + } satisfies CompiledReceiveOnlyChannelDefinition; + const withoutAuthoredChannels = { + compileResult: { manifest: { channels: [] } }, + }; + const withReceiveOnlyChannel = { + compileResult: { manifest: { channels: [receiveOnlyChannel] } }, + }; + + expect(computeChannelRouteRegistrations(withReceiveOnlyChannel)).toEqual( + computeChannelRouteRegistrations(withoutAuthoredChannels), + ); + }); + it("wraps CORS-enabled HTTP routes and registers preflight handlers", () => { const nitro = { options: { diff --git a/packages/eve/src/internal/nitro/host/channel-routes.ts b/packages/eve/src/internal/nitro/host/channel-routes.ts index b44cc4860..24ea5d73d 100644 --- a/packages/eve/src/internal/nitro/host/channel-routes.ts +++ b/packages/eve/src/internal/nitro/host/channel-routes.ts @@ -1,6 +1,7 @@ import type { Nitro } from "nitro/types"; import type { NormalizedChannelCorsOptions } from "#channel/cors.js"; +import type { CompiledAgentManifest } from "#compiler/manifest.js"; import type { ChannelRouteMethod } from "#public/definitions/channel.js"; import { getAllFrameworkChannelNames, @@ -12,7 +13,7 @@ import { resolvePackageSourceFilePath, } from "#internal/application/package.js"; import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; -import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; +import type { ResolvedChannelRouteDefinition } from "#runtime/types.js"; const EVE_CHANNEL_VIRTUAL_ID_PREFIX = "#nitro/virtual/eve-channel/"; @@ -29,11 +30,17 @@ export interface NitroChannelRouteRegistration { readonly cors?: NormalizedChannelCorsOptions; } +interface ChannelRouteManifestHost { + readonly compileResult: { + readonly manifest: Pick; + }; +} + /** * Computes the merged set of channel routes the Nitro host should mount. */ export function computeChannelRouteRegistrations( - preparedHost: PreparedApplicationHost, + preparedHost: ChannelRouteManifestHost, ): readonly NitroChannelRouteRegistration[] { const manifestChannels = preparedHost.compileResult.manifest.channels; const authoredNames = new Set(); @@ -55,11 +62,15 @@ export function computeChannelRouteRegistrations( continue; } authoredNames.add(entry.name); + if (entry.kind === "receive-only-channel") { + continue; + } authoredRoutes.push({ method: entry.method, route: entry.urlPath, cors: entry.cors }); } const activeFrameworkRoutes = getFrameworkChannelDefinitions() .filter((channel) => !authoredNames.has(channel.name) && !disabledNames.has(channel.name)) + .filter((channel): channel is ResolvedChannelRouteDefinition => channel.method !== undefined) .map( (channel): NitroChannelRouteRegistration => ({ method: channel.method, diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts index 3d052ce1c..6007baa97 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts @@ -34,7 +34,7 @@ export function buildAgentInfoResponseFromManifest( }, ): AgentInfoResponse { const manifest = data.manifest; - const authoredChannels = manifest.channels.filter((channel) => channel.kind === "channel"); + const authoredChannels = manifest.channels.filter((channel) => channel.kind !== "disabled"); const disabledFrameworkChannels = manifest.channels .filter((channel) => channel.kind === "disabled") .map((channel) => channel.name); @@ -72,10 +72,10 @@ export function buildAgentInfoResponseFromManifest( const renderedAuthoredChannels = authoredChannels.map((channel) => ({ ...toSource(channel), adapterKind: channel.adapterKind, - method: channel.method, + method: channel.kind === "channel" ? channel.method : null, name: channel.name, origin: "authored" as const, - urlPath: channel.urlPath, + urlPath: channel.kind === "channel" ? channel.urlPath : null, })); return { diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts index 86422057a..c8a8e65e5 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts @@ -113,10 +113,10 @@ export interface AgentInfoSubagentEntry extends AgentInfoSource { export interface AgentInfoChannelEntry extends AgentInfoSource { readonly adapterKind?: string; - readonly method: string; + readonly method: string | null; readonly name: string; readonly origin: "authored" | "framework"; - readonly urlPath: string; + readonly urlPath: string | null; } export interface AgentInfoFrameworkChannelEntry extends AgentInfoChannelEntry { @@ -448,10 +448,10 @@ export function renderChannel( return { ...toSource(channel), adapterKind: channel.adapter?.kind, - method: channel.method, + method: channel.method ?? null, name: channel.name, origin: input.origin, - urlPath: channel.urlPath, + urlPath: channel.urlPath ?? null, }; } diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts index a12c9db45..96e4fca60 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts @@ -202,17 +202,13 @@ describe("dispatchChannelRequest", () => { urlPath: "/webhook", } satisfies ResolvedChannelDefinition, { - handler: async () => new Response("ok"), - fetch: async () => new Response("ok"), adapter: { kind: "channel:target" }, definition: targetDefinition, receive: targetReceive, logicalPath: "agent/channels/target.ts", - method: "POST", name: "target", sourceId: "channel-target", sourceKind: "module", - urlPath: "/target", } satisfies ResolvedChannelDefinition, ], runtime, diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts index e3684ab4d..a6cee993b 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts @@ -22,6 +22,7 @@ import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifa import { resolveNitroChannelRuntimeBundle } from "#internal/nitro/routes/runtime-stack.js"; import { readVercelProjectLink } from "#internal/vercel/project-link.js"; import { withVercelOidcProjectResolver } from "#runtime/governance/auth/vercel-oidc-project.js"; +import type { ResolvedChannelDefinition, ResolvedChannelRouteDefinition } from "#runtime/types.js"; const log = createLogger("channel.dispatch"); @@ -56,7 +57,7 @@ export async function dispatchChannelRequest( const bundle = await resolveNitroChannelRuntimeBundle(config); const matchedChannel = bundle.channels.find( - (channel) => `${channel.method.toUpperCase()} ${channel.urlPath}` === routeKey, + (channel): channel is ResolvedChannelRouteDefinition => matchesChannelRoute(channel, routeKey), ); if (matchedChannel === undefined) { @@ -111,7 +112,7 @@ export async function dispatchChannelWebSocketRequest( const bundle = await resolveNitroChannelRuntimeBundle(config); const matchedChannel = bundle.channels.find( - (channel) => `${channel.method.toUpperCase()} ${channel.urlPath}` === routeKey, + (channel): channel is ResolvedChannelRouteDefinition => matchesChannelRoute(channel, routeKey), ); if (matchedChannel === undefined || matchedChannel.websocket === undefined) { @@ -145,6 +146,16 @@ export async function dispatchChannelWebSocketRequest( } } +function matchesChannelRoute( + channel: ResolvedChannelDefinition, + routeKey: string, +): channel is ResolvedChannelRouteDefinition { + return ( + channel.method !== undefined && + `${channel.method.toUpperCase()} ${channel.urlPath}` === routeKey + ); +} + async function withDevelopmentVercelOidcContext( config: NitroArtifactsConfig, request: Request, diff --git a/packages/eve/src/internal/vercel-agent-summary.ts b/packages/eve/src/internal/vercel-agent-summary.ts index 060a5c3e3..7018d550d 100644 --- a/packages/eve/src/internal/vercel-agent-summary.ts +++ b/packages/eve/src/internal/vercel-agent-summary.ts @@ -37,7 +37,7 @@ export const VERCEL_EVE_AGENT_SUMMARY_KIND = "vercel-eve-agent-summary" as const * making semantic changes consumers must opt into. Adding optional fields * does not require a version bump. */ -export const VERCEL_EVE_AGENT_SUMMARY_VERSION = 3; +export const VERCEL_EVE_AGENT_SUMMARY_VERSION = 4; /** * Output path (relative to the agent's `appRoot`) where eve writes the @@ -55,7 +55,7 @@ export const VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH = ".eve/agent-summary.json"; /** * Display category eve exposes to the dashboard for one channel chip. Built - * from the channel's reported {@link CompiledChannelDefinition.adapterKind}. + * from the channel's reported compiled `adapterKind`. */ export type VercelEveChannelType = "slack" | "http" | "webhook" | "unknown"; @@ -142,9 +142,13 @@ export interface VercelEveConnectionEntry { export interface VercelEveChannelEntry { readonly name: string; - readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "WEBSOCKET"; - readonly urlPath: string; + readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "WEBSOCKET" | null; + readonly urlPath: string | null; readonly type: VercelEveChannelType; + /** + * True when the channel only accepts schedule and cross-channel handoffs. + */ + readonly receiveOnly?: true; /** * The raw `ChannelAdapter.kind` reported by the route, when present. * Useful when `type` is `"unknown"` and a consumer wants to render a diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index a96d8d01d..c8c4227a7 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -262,11 +262,12 @@ export type ReceiveInput> = GenericReceiveInput; /** - * The object passed to {@link defineChannel}. `routes` is required; `state` - * seeds durable adapter state, `context` builds the per-step `channel` argument - * for `events` and `deliver`, `events` handle session lifecycle, `receive` - * accepts cross-channel handoffs, `fetchFile` stages remote file URLs, and - * `metadata` projects observability data. + * The object passed to {@link defineChannel}. `routes` may be empty for a + * receive-only channel; `state` seeds durable adapter state, `context` builds + * the per-step `channel` argument for `events` and `deliver`, `events` handle + * session lifecycle, `receive` accepts schedule and cross-channel handoffs, + * `fetchFile` stages remote file URLs, and `metadata` projects observability + * data. * * Generics: `TState` (adapter state), `TCtx` (context factory return type), * `TReceiveTarget` (cross-channel target shape), `TMetadata` (instrumentation diff --git a/packages/eve/src/runtime/resolve-channel.test.ts b/packages/eve/src/runtime/resolve-channel.test.ts new file mode 100644 index 000000000..fc24ac8c6 --- /dev/null +++ b/packages/eve/src/runtime/resolve-channel.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CompiledModuleMap } from "#compiler/module-map.js"; +import type { CompiledReceiveOnlyChannelDefinition } from "#compiler/manifest.js"; +import { defineChannel } from "#public/definitions/channel.js"; +import { resolveChannelDefinition } from "#runtime/resolve-channel.js"; + +const mocks = vi.hoisted(() => ({ + loadResolvedModuleExport: vi.fn(), +})); + +vi.mock("#runtime/resolve-helpers.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadResolvedModuleExport: mocks.loadResolvedModuleExport, +})); + +describe("resolveChannelDefinition", () => { + it("resolves a receive-only channel without fabricating a network route", async () => { + const authored = defineChannel({ + routes: [], + async receive(input, { send }) { + return await send(input.message, { + auth: input.auth, + continuationToken: "learning", + mode: "task", + }); + }, + }); + mocks.loadResolvedModuleExport.mockResolvedValue(authored); + const definition = { + adapterKind: "http", + kind: "receive-only-channel", + logicalPath: "channels/learning.ts", + name: "learning", + sourceId: "channels/learning.ts", + sourceKind: "module", + } satisfies CompiledReceiveOnlyChannelDefinition; + + const resolved = await resolveChannelDefinition(definition, {} as CompiledModuleMap, undefined); + + expect(resolved).toMatchObject({ + adapter: expect.objectContaining({ kind: "http" }), + definition: authored, + logicalPath: "channels/learning.ts", + name: "learning", + receive: authored.receive, + sourceId: "channels/learning.ts", + sourceKind: "module", + }); + expect(resolved.method).toBeUndefined(); + expect(resolved.urlPath).toBeUndefined(); + expect(resolved.fetch).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/runtime/resolve-channel.ts b/packages/eve/src/runtime/resolve-channel.ts index 358febf58..f8572fb30 100644 --- a/packages/eve/src/runtime/resolve-channel.ts +++ b/packages/eve/src/runtime/resolve-channel.ts @@ -1,6 +1,9 @@ import { setChannelInstrumentationKind } from "#channel/compiled-channel.js"; import { HTTP_ADAPTER_KIND } from "#channel/http.js"; -import type { CompiledChannelDefinition } from "#compiler/manifest.js"; +import type { + CompiledActiveChannelDefinition, + CompiledChannelDefinition, +} from "#compiler/manifest.js"; import type { CompiledModuleMap } from "#compiler/module-map.js"; import { isHttpRouteDefinition, @@ -30,7 +33,7 @@ import type { ResolvedChannelDefinition } from "#runtime/types.js"; * this resolver. */ export async function resolveChannelDefinition( - definition: CompiledChannelDefinition, + definition: CompiledActiveChannelDefinition, moduleMap: CompiledModuleMap, nodeId: string | undefined, ): Promise { @@ -52,12 +55,6 @@ export async function resolveChannelDefinition( sourceId: definition.sourceId, }); - const matchedRoute = channelDefinition.routes.find( - (route) => - route.method.toUpperCase() === definition.method.toUpperCase() && - route.path === definition.urlPath, - ); - const channelKind = `channel:${definition.name}`; setChannelInstrumentationKind(channelDefinition, channelKind); @@ -67,6 +64,22 @@ export async function resolveChannelDefinition( (adapter as { kind: string }).kind = channelKind; } + if (definition.kind === "receive-only-channel") { + return { + name: definition.name, + receive: channelDefinition.receive, + definition: channelDefinition, + adapter, + ...sourceRef, + }; + } + + const matchedRoute = channelDefinition.routes.find( + (route) => + route.method.toUpperCase() === definition.method.toUpperCase() && + route.path === definition.urlPath, + ); + const httpRoute = resolveHttpRoute(definition, matchedRoute); const websocketRoute = resolveWebSocketRoute(definition, matchedRoute); diff --git a/packages/eve/src/runtime/types.ts b/packages/eve/src/runtime/types.ts index 8e10abea7..2e87da0e6 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -214,20 +214,13 @@ export interface ResolvedHookDefinition extends ResolvedModuleSourceRef { } /** - * Runtime-owned authored channel definition resolved from the compiled - * module map. Channels are uniform fetch handlers — there is no per-platform - * subtype. - * - * Supports both old Route-style `fetch` handlers and new CompiledChannel - * route handlers. The dispatch layer checks for `handler` first. + * Fields shared by routed and receive-only authored channels resolved from + * the compiled module map. */ -export interface ResolvedChannelDefinition extends ResolvedModuleSourceRef { +interface ResolvedChannelCommon extends ResolvedModuleSourceRef { readonly name: string; - readonly method: ChannelRouteMethod; readonly adapter?: ChannelAdapter; readonly cors?: NormalizedChannelCorsOptions; - readonly urlPath: string; - readonly fetch: (req: Request, ctx: RouteContext) => Promise; /** * Universal entry point for new sessions, called by cross-channel * initiators (the schedule dispatcher today). Typed precisely as @@ -249,6 +242,15 @@ export interface ResolvedChannelDefinition extends ResolvedModuleSourceRef { * `defineChannel`. */ readonly definition?: CompiledChannel; +} + +/** + * Runtime-owned authored channel route. + */ +export interface ResolvedChannelRouteDefinition extends ResolvedChannelCommon { + readonly method: ChannelRouteMethod; + readonly urlPath: string; + readonly fetch: (req: Request, ctx: RouteContext) => Promise; /** * New-style route handler from CompiledChannel. When present, the * dispatch layer uses this instead of `fetch`. @@ -261,6 +263,21 @@ export interface ResolvedChannelDefinition extends ResolvedModuleSourceRef { readonly websocket?: WebSocketRouteHandler; } +/** + * Runtime-owned authored channel that only accepts proactive `receive` calls. + */ +export interface ResolvedReceiveOnlyChannelDefinition extends ResolvedChannelCommon { + readonly method?: undefined; + readonly urlPath?: undefined; + readonly fetch?: undefined; + readonly handler?: undefined; + readonly websocket?: undefined; +} + +export type ResolvedChannelDefinition = + | ResolvedChannelRouteDefinition + | ResolvedReceiveOnlyChannelDefinition; + /** * Runtime-owned local subagent node resolved from one compiled local * subagent package. diff --git a/packages/eve/src/shared/channel-definition.ts b/packages/eve/src/shared/channel-definition.ts index 043c9970c..5f71e38e4 100644 --- a/packages/eve/src/shared/channel-definition.ts +++ b/packages/eve/src/shared/channel-definition.ts @@ -31,11 +31,12 @@ export interface GenericReceiveInput> { } /** - * The object passed to {@link defineChannel}. `routes` is required; `state` - * seeds durable adapter state, `context` builds the per-step `channel` argument - * for `events` and `deliver`, `events` handle session lifecycle, `receive` - * accepts cross-channel handoffs, `fetchFile` stages remote file URLs, and - * `metadata` projects observability data. + * The object passed to {@link defineChannel}. `routes` may be empty for a + * receive-only channel; `state` seeds durable adapter state, `context` builds + * the per-step `channel` argument for `events` and `deliver`, `events` handle + * session lifecycle, `receive` accepts schedule and cross-channel handoffs, + * `fetchFile` stages remote file URLs, and `metadata` projects observability + * data. * * Generics: `TState` (adapter state), `TCtx` (context factory return type), * `TReceiveTarget` (cross-channel target shape), `TMetadata` (instrumentation