feat(M5-004,M5-005,M5-006,M5-007): session-conversation binding, session:info broadcast, agent creation from TUI, and session metrics (#321)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #321.
This commit is contained in:
@@ -23,7 +23,7 @@ import { createFileTools } from './tools/file-tools.js';
|
||||
import { createGitTools } from './tools/git-tools.js';
|
||||
import { createShellTools } from './tools/shell-tools.js';
|
||||
import { createWebTools } from './tools/web-tools.js';
|
||||
import type { SessionInfoDto } from './session.dto.js';
|
||||
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
@@ -93,6 +93,12 @@ export interface AgentSession {
|
||||
allowedTools: string[] | null;
|
||||
/** User ID that owns this session, used for preference lookups. */
|
||||
userId?: string;
|
||||
/** Agent config ID applied to this session, if any (M5-001). */
|
||||
agentConfigId?: string;
|
||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||
agentName?: string;
|
||||
/** M5-007: per-session metrics. */
|
||||
metrics: SessionMetrics;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -184,11 +190,13 @@ export class AgentService implements OnModuleDestroy {
|
||||
sessionId: string,
|
||||
options?: AgentSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
// Merge DB agent config when agentConfigId is provided
|
||||
// Merge DB agent config when agentConfigId is provided (M5-001)
|
||||
let mergedOptions = options;
|
||||
let resolvedAgentName: string | undefined;
|
||||
if (options?.agentConfigId) {
|
||||
const agentConfig = await this.brain.agents.findById(options.agentConfigId);
|
||||
if (agentConfig) {
|
||||
resolvedAgentName = agentConfig.name;
|
||||
mergedOptions = {
|
||||
provider: options.provider ?? agentConfig.provider,
|
||||
modelId: options.modelId ?? agentConfig.model,
|
||||
@@ -197,6 +205,8 @@ export class AgentService implements OnModuleDestroy {
|
||||
sandboxDir: options.sandboxDir,
|
||||
isAdmin: options.isAdmin,
|
||||
agentConfigId: options.agentConfigId,
|
||||
userId: options.userId,
|
||||
conversationHistory: options.conversationHistory,
|
||||
};
|
||||
this.logger.log(
|
||||
`Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`,
|
||||
@@ -330,6 +340,14 @@ export class AgentService implements OnModuleDestroy {
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
agentConfigId: mergedOptions?.agentConfigId,
|
||||
agentName: resolvedAgentName,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -458,10 +476,12 @@ export class AgentService implements OnModuleDestroy {
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: now - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -472,13 +492,93 @@ export class AgentService implements OnModuleDestroy {
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: Date.now() - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage for a session turn (M5-007).
|
||||
* Accumulates tokens across the session lifetime.
|
||||
*/
|
||||
recordTokenUsage(
|
||||
sessionId: string,
|
||||
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number },
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.metrics.tokens.input += tokens.input;
|
||||
session.metrics.tokens.output += tokens.output;
|
||||
session.metrics.tokens.cacheRead += tokens.cacheRead;
|
||||
session.metrics.tokens.cacheWrite += tokens.cacheWrite;
|
||||
session.metrics.tokens.total += tokens.total;
|
||||
session.metrics.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a model switch event for a session (M5-007).
|
||||
*/
|
||||
recordModelSwitch(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.metrics.modelSwitches += 1;
|
||||
session.metrics.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment message count for a session (M5-007).
|
||||
*/
|
||||
recordMessage(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.metrics.messageCount += 1;
|
||||
session.metrics.lastActivityAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the model tracked on a live session (M5-002).
|
||||
* This records the model change in the session metadata so subsequent
|
||||
* session:info emissions reflect the new model. The Pi session itself is
|
||||
* not reconstructed — the model is used on the next createSession call for
|
||||
* the same conversationId when the session is torn down or a new one is created.
|
||||
*/
|
||||
updateSessionModel(sessionId: string, modelId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const prev = session.modelId;
|
||||
session.modelId = modelId;
|
||||
this.recordModelSwitch(sessionId);
|
||||
this.logger.log(`Session ${sessionId}: model updated ${prev} → ${modelId} (M5-002)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new agent config to a live session mid-conversation (M5-003).
|
||||
* Updates agentName, agentConfigId, and modelId on the session object.
|
||||
* System prompt and tools take effect when the next session is created for
|
||||
* this conversationId (they are baked in at session creation time).
|
||||
*/
|
||||
applyAgentConfig(
|
||||
sessionId: string,
|
||||
agentConfigId: string,
|
||||
agentName: string,
|
||||
modelId?: string,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.agentConfigId = agentConfigId;
|
||||
session.agentName = agentName;
|
||||
if (modelId) {
|
||||
this.updateSessionModel(sessionId, modelId);
|
||||
}
|
||||
this.logger.log(
|
||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||
);
|
||||
}
|
||||
|
||||
addChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
/** Token usage metrics for a session (M5-007). */
|
||||
export interface SessionTokenMetrics {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Per-session metrics tracked throughout the session lifetime (M5-007). */
|
||||
export interface SessionMetrics {
|
||||
tokens: SessionTokenMetrics;
|
||||
modelSwitches: number;
|
||||
messageCount: number;
|
||||
lastActivityAt: string;
|
||||
}
|
||||
|
||||
export interface SessionInfoDto {
|
||||
id: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
/** M5-005: human-readable agent name when an agent config is applied. */
|
||||
agentName?: string;
|
||||
createdAt: string;
|
||||
promptCount: number;
|
||||
channels: string[];
|
||||
durationMs: number;
|
||||
/** M5-007: per-session metrics (token usage, model switches, etc.) */
|
||||
metrics: SessionMetrics;
|
||||
}
|
||||
|
||||
export interface SessionListDto {
|
||||
|
||||
@@ -119,6 +119,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||
|
||||
// M5-004: Check if there's an existing sessionId bound to this conversation
|
||||
let existingSessionId: string | undefined;
|
||||
if (userId) {
|
||||
existingSessionId = await this.getConversationSessionId(conversationId, userId);
|
||||
if (existingSessionId) {
|
||||
this.logger.log(
|
||||
`Resuming existing sessionId=${existingSessionId} for conversation=${conversationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider/model via routing engine or per-session /model override (M4-012 / M4-007)
|
||||
let resolvedProvider = data.provider;
|
||||
let resolvedModelId = data.modelId;
|
||||
@@ -153,7 +164,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
}
|
||||
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
// M5-004: Use existingSessionId as sessionId when available (session reuse)
|
||||
const sessionIdToCreate = existingSessionId ?? conversationId;
|
||||
agentSession = await this.agentService.createSession(sessionIdToCreate, {
|
||||
provider: resolvedProvider,
|
||||
modelId: resolvedModelId,
|
||||
agentConfigId: data.agentId,
|
||||
@@ -180,10 +193,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
// Ensure conversation record exists in the DB before persisting messages
|
||||
// M5-004: Also bind the sessionId to the conversation record
|
||||
if (userId) {
|
||||
await this.ensureConversation(conversationId, userId);
|
||||
await this.bindSessionToConversation(conversationId, userId, conversationId);
|
||||
}
|
||||
|
||||
// M5-007: Count the user message
|
||||
this.agentService.recordMessage(conversationId);
|
||||
|
||||
// Persist the user message
|
||||
if (userId) {
|
||||
try {
|
||||
@@ -234,6 +252,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||
// Include agentName when a named agent config is active (M5-001)
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
@@ -244,6 +263,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
modelId: agentSession.modelId,
|
||||
thinkingLevel: piSession.thinkingLevel,
|
||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||
...(agentSession.agentName ? { agentName: agentSession.agentName } : {}),
|
||||
...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}),
|
||||
});
|
||||
}
|
||||
@@ -301,6 +321,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: session.piSession.thinkingLevel,
|
||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -323,11 +344,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* Set a per-conversation model override (M4-007).
|
||||
* When set, the routing engine is bypassed and the specified model is used.
|
||||
* Pass null to clear the override and resume automatic routing.
|
||||
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
||||
* M5-007: Records a model switch in session metrics.
|
||||
*/
|
||||
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||
if (modelName) {
|
||||
modelOverrides.set(conversationId, modelName);
|
||||
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||
|
||||
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
||||
this.agentService.updateSessionModel(conversationId, modelName);
|
||||
|
||||
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
// Find all clients subscribed to this conversation and emit updated session:info
|
||||
this.broadcastSessionInfo(conversationId);
|
||||
}
|
||||
} else {
|
||||
modelOverrides.delete(conversationId);
|
||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||
@@ -341,6 +374,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
return modelOverrides.get(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* M5-005: Broadcast session:info to all clients currently subscribed to a conversation.
|
||||
* Called on model or agent switch to ensure the TUI TopBar updates immediately.
|
||||
*/
|
||||
broadcastSessionInfo(
|
||||
conversationId: string,
|
||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||
): void {
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) return;
|
||||
|
||||
const piSession = agentSession.piSession;
|
||||
const payload = {
|
||||
conversationId,
|
||||
provider: agentSession.provider,
|
||||
modelId: agentSession.modelId,
|
||||
thinkingLevel: piSession.thinkingLevel,
|
||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||
...(extra?.agentName ? { agentName: extra.agentName } : {}),
|
||||
...(extra?.routingDecision ? { routingDecision: extra.routingDecision } : {}),
|
||||
};
|
||||
|
||||
// Emit to all clients currently subscribed to this conversation
|
||||
for (const [clientId, session] of this.clientSessions) {
|
||||
if (session.conversationId === conversationId) {
|
||||
const socket = this.server.sockets.sockets.get(clientId);
|
||||
if (socket?.connected) {
|
||||
socket.emit('session:info', payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a conversation record exists in the DB.
|
||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||
@@ -363,6 +429,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M5-004: Bind the agent sessionId to the conversation record in the DB.
|
||||
* Updates the sessionId column so future resumes can reuse the session.
|
||||
*/
|
||||
private async bindSessionToConversation(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.brain.conversations.update(conversationId, userId, { sessionId });
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to bind sessionId=${sessionId} to conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M5-004: Retrieve the sessionId bound to a conversation, if any.
|
||||
* Returns undefined when the conversation does not exist or has no bound session.
|
||||
*/
|
||||
private async getConversationSessionId(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const conv = await this.brain.conversations.findById(conversationId, userId);
|
||||
return conv?.sessionId ?? undefined;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to get sessionId for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load prior conversation messages from DB for context injection on session resume (M1-004).
|
||||
* Returns an empty array when no history exists, the conversation is not owned by the user,
|
||||
@@ -439,6 +544,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
usage: usagePayload,
|
||||
});
|
||||
|
||||
// M5-007: Accumulate token usage in session metrics
|
||||
if (stats?.tokens) {
|
||||
this.agentService.recordTokenUsage(conversationId, {
|
||||
input: stats.tokens.input ?? 0,
|
||||
output: stats.tokens.output ?? 0,
|
||||
cacheRead: stats.tokens.cacheRead ?? 0,
|
||||
cacheWrite: stats.tokens.cacheWrite ?? 0,
|
||||
total: stats.tokens.total ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist the assistant message with metadata
|
||||
const cs = this.clientSessions.get(client.id);
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||
|
||||
@@ -19,6 +19,8 @@ const mockRegistry = {
|
||||
|
||||
const mockAgentService = {
|
||||
getSession: vi.fn(() => undefined),
|
||||
applyAgentConfig: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSystemOverride = {
|
||||
@@ -38,6 +40,33 @@ const mockRedis = {
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAgentConfig = {
|
||||
id: 'my-agent-id',
|
||||
name: 'my-agent-id',
|
||||
model: 'claude-sonnet-4-6',
|
||||
provider: 'anthropic',
|
||||
systemPrompt: null,
|
||||
allowedTools: null,
|
||||
isSystem: false,
|
||||
ownerId: 'user-123',
|
||||
status: 'idle',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockBrain = {
|
||||
agents: {
|
||||
// findByName resolves with the agent when name matches, undefined otherwise
|
||||
findByName: vi.fn((name: string) =>
|
||||
Promise.resolve(name === 'my-agent-id' ? mockAgentConfig : undefined),
|
||||
),
|
||||
findById: vi.fn((id: string) =>
|
||||
Promise.resolve(id === 'my-agent-id' ? mockAgentConfig : undefined),
|
||||
),
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
function buildService(): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
@@ -45,6 +74,7 @@ function buildService(): CommandExecutorService {
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
mockBrain as never,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaic/queue';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
@@ -8,6 +9,7 @@ import { SystemOverrideService } from '../preferences/system-override.service.js
|
||||
import { ReloadService } from '../reload/reload.service.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
|
||||
@Injectable()
|
||||
export class CommandExecutorService {
|
||||
@@ -19,6 +21,7 @@ export class CommandExecutorService {
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
private readonly reloadService: ReloadService | null,
|
||||
@@ -87,7 +90,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId);
|
||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
@@ -239,12 +242,14 @@ export class CommandExecutorService {
|
||||
private async handleAgent(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
|
||||
message:
|
||||
'Usage: /agent <agent-id> | /agent list | /agent new <name> to create a new agent.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -258,13 +263,101 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
// Switch agent — stub for now (full implementation in P8-015)
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
||||
conversationId,
|
||||
};
|
||||
// M5-006: /agent new <name> — create a new agent config via brain.agents.create()
|
||||
if (args.startsWith('new')) {
|
||||
const namePart = args.slice(3).trim();
|
||||
if (!namePart) {
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: 'Usage: /agent new <name> — provide a name for the new agent.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultProvider = process.env['DEFAULT_PROVIDER'] ?? 'anthropic';
|
||||
const defaultModel = process.env['DEFAULT_MODEL'] ?? 'claude-sonnet-4-5-20251001';
|
||||
|
||||
const newAgent = await this.brain.agents.create({
|
||||
name: namePart,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
status: 'idle',
|
||||
ownerId: userId,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
this.logger.log(`Created new agent "${newAgent.name}" (${newAgent.id}) for user ${userId}`);
|
||||
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: `Agent "${newAgent.name}" created with ID: ${newAgent.id}. Configure it via the web dashboard.`,
|
||||
conversationId,
|
||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create agent: ${err}`);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Failed to create agent: ${String(err)}`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// M5-003: Look up agent by name (or ID) and apply to session mid-conversation
|
||||
const agentName = args.trim();
|
||||
try {
|
||||
// Try lookup by name first; fall back to ID-based lookup
|
||||
let agentConfig = await this.brain.agents.findByName(agentName);
|
||||
if (!agentConfig) {
|
||||
// Try by ID (UUID-style input)
|
||||
agentConfig = await this.brain.agents.findById(agentName);
|
||||
}
|
||||
|
||||
if (!agentConfig) {
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Agent "${agentName}" not found. Use /agent list to see available agents.`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply the agent config to the live session and emit session:info (M5-003)
|
||||
this.agentService.applyAgentConfig(
|
||||
conversationId,
|
||||
agentConfig.id,
|
||||
agentConfig.name,
|
||||
agentConfig.model ?? undefined,
|
||||
);
|
||||
|
||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||
|
||||
this.logger.log(
|
||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||
);
|
||||
|
||||
return {
|
||||
command: 'agent',
|
||||
success: true,
|
||||
message: `Switched to agent "${agentConfig.name}". System prompt and tools applied. Model: ${agentConfig.model ?? 'default'}.`,
|
||||
conversationId,
|
||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Failed to switch agent: ${String(err)}`,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProvider(
|
||||
|
||||
@@ -39,6 +39,14 @@ const mockRedis = {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockBrain = {
|
||||
agents: {
|
||||
findByName: vi.fn().mockResolvedValue(undefined),
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
@@ -54,6 +62,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
mockBrain as never,
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
);
|
||||
|
||||
1
packages/db/drizzle/0006_swift_shen.sql
Normal file
1
packages/db/drizzle/0006_swift_shen.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "conversations" ADD COLUMN "session_id" text;
|
||||
2768
packages/db/drizzle/meta/0006_snapshot.json
Normal file
2768
packages/db/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1774225763410,
|
||||
"tag": "0005_minor_champions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1774227064500,
|
||||
"tag": "0006_swift_shen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -319,6 +319,8 @@ export const conversations = pgTable(
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||
agentId: uuid('agent_id').references(() => agents.id, { onDelete: 'set null' }),
|
||||
/** M5-004: Agent session ID bound to this conversation. Nullable — set when a session is created. */
|
||||
sessionId: text('session_id'),
|
||||
archived: boolean('archived').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user