feat(tess): add generic interaction CLI (#731)
This commit was merged in pull request #731.
This commit is contained in:
@@ -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);
|
||||
|
||||
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 ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
|
||||
Reference in New Issue
Block a user