From de74e46a0640c44c6b7294c24669496980761c92 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 13 Jul 2026 00:42:42 -0500 Subject: [PATCH] feat(tess): add generic interaction CLI --- apps/gateway/src/agent/agent.module.ts | 9 +- .../src/agent/interaction.controller.test.ts | 70 ++++++ .../src/agent/interaction.controller.ts | 192 +++++++++++++++++ .../src/agent/tess-durable-session.service.ts | 18 +- packages/mosaic/src/cli.ts | 5 + .../mosaic/src/commands/interaction.test.ts | 22 ++ packages/mosaic/src/commands/interaction.ts | 200 ++++++++++++++++++ packages/mosaic/src/tui/gateway-api.ts | 132 ++++++++++++ 8 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/agent/interaction.controller.test.ts create mode 100644 apps/gateway/src/agent/interaction.controller.ts create mode 100644 packages/mosaic/src/commands/interaction.test.ts create mode 100644 packages/mosaic/src/commands/interaction.ts diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 8e90ef2..f563bcc 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -9,6 +9,7 @@ import { SkillLoaderService } from './skill-loader.service.js'; import { ProvidersController } from './providers.controller.js'; import { SessionsController } from './sessions.controller.js'; import { AgentConfigsController } from './agent-configs.controller.js'; +import { InteractionController } from './interaction.controller.js'; import { RoutingController } from './routing/routing.controller.js'; import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; import { TessDurableSessionService } from './tess-durable-session.service.js'; @@ -54,7 +55,13 @@ import { RuntimeProviderService, AgentService, ], - controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], + controllers: [ + ProvidersController, + SessionsController, + AgentConfigsController, + InteractionController, + RoutingController, + ], exports: [ AgentService, ProviderService, diff --git a/apps/gateway/src/agent/interaction.controller.test.ts b/apps/gateway/src/agent/interaction.controller.test.ts new file mode 100644 index 0000000..8e7c382 --- /dev/null +++ b/apps/gateway/src/agent/interaction.controller.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InteractionController } from './interaction.controller.js'; + +describe('InteractionController', (): void => { + afterEach(() => vi.restoreAllMocks()); + + it('honors a differently named configured instance without a code change', async () => { + const prior = process.env['MOSAIC_AGENT_NAME']; + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { listSessions: vi.fn().mockResolvedValue([]) }; + const controller = new InteractionController(runtime as never, {} as never); + + await expect( + controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'), + ).resolves.toEqual([]); + await expect( + controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'), + ).rejects.toThrow('Interaction agent is not configured'); + + if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME']; + else process.env['MOSAIC_AGENT_NAME'] = prior; + }); + + it('rejects a request without the non-simple correlation header', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never); + + await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow( + 'X-Correlation-Id is required', + ); + }); + + it('rejects an invalid attach mode before invoking a provider', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const controller = new InteractionController({ attach: vi.fn() } as never, {} as never); + + await expect( + controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'), + ).rejects.toThrow('Interaction attach mode is invalid'); + }); + + it('uses the durable session identity and runtime registry for an approved stop', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { terminate: vi.fn().mockResolvedValue(undefined) }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ); + + expect(runtime.terminate).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + 'approval-1', + expect.objectContaining({ + correlationId: 'corr-1', + actorScope: { userId: 'owner', tenantId: 'owner' }, + }), + ); + }); +}); diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts new file mode 100644 index 0000000..5103660 --- /dev/null +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -0,0 +1,192 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Headers, + Inject, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import type { RuntimeAttachMode } from '@mosaicstack/types'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { + RuntimeProviderService, + type RuntimeProviderRequestContext, +} from './runtime-provider-registry.service.js'; + +/** + * Authenticated HTTP boundary for operator interaction clients. Identity is + * selected from deployment configuration, never a client-side command name. + */ +@Controller('api/interaction/:agentName') +@UseGuards(AuthGuard) +export class InteractionController { + constructor( + @Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService, + @Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService, + ) {} + + @Get('sessions') + async sessions( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.listSessions( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + @Get('tree') + async tree( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.getSessionTree( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + @Post('sessions/:sessionId/attach') + async attach( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { mode?: RuntimeAttachMode } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + const mode = body.mode ?? 'read'; + if (mode !== 'read' && mode !== 'control') { + throw new ForbiddenException('Interaction attach mode is invalid'); + } + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + return this.runtime.attach( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + mode, + context, + ); + } + + @Post('sessions/:sessionId/send') + async send( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { content?: string; idempotencyKey?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + if (!body.content?.trim() || !body.idempotencyKey?.trim()) { + throw new ForbiddenException('Content and idempotency key are required'); + } + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + const input = { + sessionId, + content: body.content, + idempotencyKey: body.idempotencyKey, + correlationId: context.correlationId, + context, + }; + await this.durable.queueProviderSend(input); + await this.durable.dispatchProviderOutbox(sessionId, input); + return { status: 'queued', sessionId }; + } + + @Post('sessions/:sessionId/stop') + async stop( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { approvalRef?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + if (!body.approvalRef?.trim()) + throw new ForbiddenException('Exact-action approval is required'); + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + await this.runtime.terminate( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + body.approvalRef, + context, + ); + return { status: 'stopped', sessionId }; + } + + @Post('sessions/:sessionId/recover') + async recover( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + await this.durable.recoverProviderSession(sessionId, { + sessionId, + content: '', + idempotencyKey: `recovery:${context.correlationId}`, + correlationId: context.correlationId, + context, + }); + return { status: 'recovered', sessionId }; + } + + private context( + user: AuthenticatedUserLike, + correlationId?: string, + ): RuntimeProviderRequestContext { + const requestCorrelationId = correlationId?.trim(); + // This non-simple request header is mandatory for mutations. Browser + // cross-origin requests cannot set it without a CORS preflight, and the + // gateway's allowlist rejects untrusted origins before the handler runs. + if (!requestCorrelationId) { + throw new ForbiddenException('X-Correlation-Id is required'); + } + return { + actorScope: scopeFromUser(user), + channelId: 'cli', + correlationId: requestCorrelationId, + }; + } + + private assertConfiguredAgent(agentName: string): void { + const configured = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!configured || configured !== agentName) { + throw new ForbiddenException('Interaction agent is not configured for this request'); + } + } + + private assertSessionAgent(sessionAgentName: string, agentName: string): void { + if (sessionAgentName !== agentName) + throw new ForbiddenException('Interaction session identity mismatch'); + } + + private requiredProvider(providerId: string): string { + if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required'); + return providerId; + } +} diff --git a/apps/gateway/src/agent/tess-durable-session.service.ts b/apps/gateway/src/agent/tess-durable-session.service.ts index 095448b..2dc3619 100644 --- a/apps/gateway/src/agent/tess-durable-session.service.ts +++ b/apps/gateway/src/agent/tess-durable-session.service.ts @@ -2,7 +2,10 @@ import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { DurableSessionCoordinator } from '@mosaicstack/agent'; import type { TessProviderOutboxDto } from './tess-durable-session.dto.js'; import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; -import { RuntimeProviderService } from './runtime-provider-registry.service.js'; +import { + RuntimeProviderService, + type RuntimeProviderRequestContext, +} from './runtime-provider-registry.service.js'; /** * Scoped gateway boundary for the canonical Tess state machine. It deliberately @@ -61,6 +64,19 @@ export class TessDurableSessionService { ); } + /** Read durable identity/state only after deriving and checking the server-side actor scope. */ + async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) { + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, { + sessionId, + content: '', + idempotencyKey: 'read-only', + correlationId: context.correlationId, + context, + }); + return snapshot; + } + /** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */ async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise { const snapshot = await this.coordinator.snapshot(sessionId); diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 4381b84..8fc5fad 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -12,6 +12,7 @@ import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerAgentCommand } from './commands/agent.js'; +import { registerInteractionCommand } from './commands/interaction.js'; import { registerConfigCommand } from './commands/config.js'; import { registerFleetCommand } from './commands/fleet.js'; import { registerMissionCommand } from './commands/mission.js'; @@ -352,6 +353,10 @@ registerFederationCommand(program); registerAgentCommand(program); +// ─── interaction ─────────────────────────────────────────────────────── + +registerInteractionCommand(program); + // ─── fleet ───────────────────────────────────────────────────────────── registerFleetCommand(program); diff --git a/packages/mosaic/src/commands/interaction.test.ts b/packages/mosaic/src/commands/interaction.test.ts new file mode 100644 index 0000000..79cd774 --- /dev/null +++ b/packages/mosaic/src/commands/interaction.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { Command } from 'commander'; +import { registerInteractionCommand } from './interaction.js'; + +describe('generic interaction CLI', (): void => { + it('registers every required interaction verb without an instance-specific command name', () => { + const program = new Command(); + const command = registerInteractionCommand(program); + expect(command.name()).toBe('interaction'); + expect(command.commands.map((item) => item.name()).sort()).toEqual([ + 'attach', + 'chat', + 'health', + 'recover', + 'send', + 'sessions', + 'status', + 'stop', + 'tree', + ]); + }); +}); diff --git a/packages/mosaic/src/commands/interaction.ts b/packages/mosaic/src/commands/interaction.ts new file mode 100644 index 0000000..8b53a70 --- /dev/null +++ b/packages/mosaic/src/commands/interaction.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto'; +import type { Command } from 'commander'; +import { withAuth } from './with-auth.js'; +import { + attachInteractionSession, + fetchInteractionHealth, + fetchInteractionSessions, + fetchInteractionStatus, + fetchInteractionTree, + recoverInteractionSession, + sendInteractionMessage, + stopInteractionSession, +} from '../tui/gateway-api.js'; + +interface InteractionOptions { + gateway: string; + agent?: string; + correlationId?: string; +} + +function configuredAgentName(options: InteractionOptions): string { + const name = options.agent?.trim() || process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!name) throw new Error('Interaction agent name is required (--agent or MOSAIC_AGENT_NAME)'); + return name; +} + +async function authenticatedGateway(options: InteractionOptions) { + return withAuth(options.gateway); +} + +async function authRequest(options: InteractionOptions) { + const auth = await authenticatedGateway(options); + return { + gateway: auth.gateway, + cookie: auth.cookie, + agentName: configuredAgentName(options), + correlationId: options.correlationId?.trim() || randomUUID(), + }; +} + +function options(command: Command): Command { + return command + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:14242') + .option('--agent ', 'Configured interaction agent name') + .option('--correlation-id ', 'Correlation ID for audit tracing'); +} + +function print(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +/** + * Generic interaction command surface. The deployed instance name is data + * (`--agent` / MOSAIC_AGENT_NAME), not a source-code command literal. + */ +export function registerInteractionCommand(program: Command): Command { + const command = program + .command('interaction') + .description('Operate a configured durable interaction agent') + .configureHelp({ sortSubcommands: true }); + + options( + command + .command('status') + .description('Show credential-safe effective policy and provider status'), + ).action(async (opts: InteractionOptions) => { + const auth = await authenticatedGateway(opts); + print(await fetchInteractionStatus(auth.gateway, auth.cookie)); + }); + + options( + command + .command('health') + .description('Show readiness without configuration or credential data'), + ).action(async (opts: InteractionOptions) => { + print(await fetchInteractionHealth(opts.gateway)); + }); + + options( + command + .command('sessions ') + .description('List provider sessions visible to this actor'), + ).action(async (provider: string, opts: InteractionOptions) => { + const request = await authRequest(opts); + print( + await fetchInteractionSessions(request.gateway, request.cookie, { + ...request, + providerId: provider, + }), + ); + }); + + options( + command.command('tree ').description('Show the provider session hierarchy'), + ).action(async (provider: string, opts: InteractionOptions) => { + const request = await authRequest(opts); + print( + await fetchInteractionTree(request.gateway, request.cookie, { + ...request, + providerId: provider, + }), + ); + }); + + options( + command.command('attach ').description('Create a scoped read or write attachment'), + ) + .option('--control', 'Request control mode (provider policy may deny it)') + .action(async (sessionId: string, opts: InteractionOptions & { control?: boolean }) => { + const request = await authRequest(opts); + print( + await attachInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + mode: opts.control ? 'control' : 'read', + }), + ); + }); + + const send = options( + command + .command('send ') + .description('Durably queue and dispatch a message'), + ) + .option('--idempotency-key ', 'Stable idempotency key') + .action( + async ( + sessionId: string, + message: string, + opts: InteractionOptions & { idempotencyKey?: string }, + ) => { + const request = await authRequest(opts); + print( + await sendInteractionMessage(request.gateway, request.cookie, { + ...request, + sessionId, + content: message, + idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(), + }), + ); + }, + ); + void send; + + options( + command + .command('chat ') + .description('Send a chat message through the durable interaction session'), + ) + .option('--idempotency-key ', 'Stable idempotency key') + .action( + async ( + sessionId: string, + message: string, + opts: InteractionOptions & { idempotencyKey?: string }, + ) => { + const request = await authRequest(opts); + print( + await sendInteractionMessage(request.gateway, request.cookie, { + ...request, + sessionId, + content: message, + idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(), + }), + ); + }, + ); + + options( + command + .command('stop ') + .description('Terminate a session using a one-time exact-action approval'), + ) + .requiredOption('--approval ', 'Durable exact-action approval reference') + .action(async (sessionId: string, opts: InteractionOptions & { approval: string }) => { + const request = await authRequest(opts); + print( + await stopInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + approvalRef: opts.approval, + }), + ); + }); + + options( + command + .command('recover ') + .description( + 'Recover durable inbox/checkpoint state without replaying ambiguous outbox effects', + ), + ).action(async (sessionId: string, opts: InteractionOptions) => { + const request = await authRequest(opts); + print( + await recoverInteractionSession(request.gateway, request.cookie, { ...request, sessionId }), + ); + }); + + return command; +} diff --git a/packages/mosaic/src/tui/gateway-api.ts b/packages/mosaic/src/tui/gateway-api.ts index 34147ab..d5ad4dd 100644 --- a/packages/mosaic/src/tui/gateway-api.ts +++ b/packages/mosaic/src/tui/gateway-api.ts @@ -361,6 +361,138 @@ export async function deleteMission( } } +// ── Authenticated interaction runtime endpoints ── + +export interface InteractionRequest { + agentName: string; + correlationId?: string; +} + +function interactionHeaders(sessionCookie: string, gatewayUrl: string, correlationId?: string) { + return { + ...jsonHeaders(sessionCookie, gatewayUrl), + ...(correlationId ? { 'X-Correlation-Id': correlationId } : {}), + }; +} + +function interactionPath(agentName: string, suffix: string): string { + return `/api/interaction/${encodeURIComponent(agentName)}${suffix}`; +} + +export async function fetchInteractionSessions( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { providerId: string }, +): Promise { + const params = new URLSearchParams({ provider: request.providerId }); + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions?${params}`)}`, + { + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + }, + ); + return handleResponse(res, 'Failed to list interaction sessions'); +} + +export async function fetchInteractionTree( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { providerId: string }, +): Promise { + const params = new URLSearchParams({ provider: request.providerId }); + const res = await fetch(`${gatewayUrl}${interactionPath(request.agentName, `/tree?${params}`)}`, { + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + }); + return handleResponse(res, 'Failed to get interaction session tree'); +} + +export async function attachInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; mode?: 'read' | 'control' }, +): Promise { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/attach`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ mode: request.mode ?? 'read' }), + }, + ); + return handleResponse(res, 'Failed to attach interaction session'); +} + +export async function sendInteractionMessage( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; content: string; idempotencyKey: string }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/send`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ content: request.content, idempotencyKey: request.idempotencyKey }), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to send interaction message', + ); +} + +export async function stopInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; approvalRef: string }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stop`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ approvalRef: request.approvalRef }), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to stop interaction session', + ); +} + +export async function recoverInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/recover`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to recover interaction session', + ); +} + +export async function fetchInteractionStatus( + gatewayUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/providers/status`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to get effective interaction policy'); +} + +export async function fetchInteractionHealth(gatewayUrl: string): Promise { + const res = await fetch(`${gatewayUrl}/health/ready`); + return handleResponse(res, 'Failed to get interaction readiness'); +} + // ── Conversation Message types ── export interface ConversationMessage { -- 2.49.1