feat(tess): add generic interaction CLI
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -9,6 +9,7 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
|||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
|
import { InteractionController } from './interaction.controller.js';
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
@@ -54,7 +55,13 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
controllers: [
|
||||||
|
ProvidersController,
|
||||||
|
SessionsController,
|
||||||
|
AgentConfigsController,
|
||||||
|
InteractionController,
|
||||||
|
RoutingController,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AgentService,
|
AgentService,
|
||||||
ProviderService,
|
ProviderService,
|
||||||
|
|||||||
70
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
70
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
@@ -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' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
apps/gateway/src/agent/interaction.controller.ts
Normal file
192
apps/gateway/src/agent/interaction.controller.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
|||||||
import { DurableSessionCoordinator } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator } from '@mosaicstack/agent';
|
||||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.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
|
* 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. */
|
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||||
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { registerQueueCommand } from '@mosaicstack/queue';
|
|||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
@@ -352,6 +353,10 @@ registerFederationCommand(program);
|
|||||||
|
|
||||||
registerAgentCommand(program);
|
registerAgentCommand(program);
|
||||||
|
|
||||||
|
// ─── interaction ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerInteractionCommand(program);
|
||||||
|
|
||||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
|
|||||||
22
packages/mosaic/src/commands/interaction.test.ts
Normal file
22
packages/mosaic/src/commands/interaction.test.ts
Normal file
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
200
packages/mosaic/src/commands/interaction.ts
Normal file
200
packages/mosaic/src/commands/interaction.ts
Normal file
@@ -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 <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('--agent <name>', 'Configured interaction agent name')
|
||||||
|
.option('--correlation-id <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 <provider>')
|
||||||
|
.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 <provider>').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 <sessionId>').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 <sessionId> <message>')
|
||||||
|
.description('Durably queue and dispatch a message'),
|
||||||
|
)
|
||||||
|
.option('--idempotency-key <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 <sessionId> <message>')
|
||||||
|
.description('Send a chat message through the durable interaction session'),
|
||||||
|
)
|
||||||
|
.option('--idempotency-key <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 <sessionId>')
|
||||||
|
.description('Terminate a session using a one-time exact-action approval'),
|
||||||
|
)
|
||||||
|
.requiredOption('--approval <ref>', '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 <sessionId>')
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -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<unknown[]> {
|
||||||
|
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<unknown[]>(res, 'Failed to list interaction sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInteractionTree(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { providerId: string },
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
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<unknown[]>(res, 'Failed to get interaction session tree');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachInteractionSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { sessionId: string; mode?: 'read' | 'control' },
|
||||||
|
): Promise<unknown> {
|
||||||
|
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<unknown>(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<unknown> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/providers/status`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<unknown>(res, 'Failed to get effective interaction policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknown> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/health/ready`);
|
||||||
|
return handleResponse<unknown>(res, 'Failed to get interaction readiness');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Conversation Message types ──
|
// ── Conversation Message types ──
|
||||||
|
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user