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 { createGitTools } from './tools/git-tools.js';
|
||||||
import { createShellTools } from './tools/shell-tools.js';
|
import { createShellTools } from './tools/shell-tools.js';
|
||||||
import { createWebTools } from './tools/web-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 { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
@@ -93,6 +93,12 @@ export interface AgentSession {
|
|||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
/** User ID that owns this session, used for preference lookups. */
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
userId?: string;
|
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()
|
@Injectable()
|
||||||
@@ -184,11 +190,13 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: AgentSessionOptions,
|
options?: AgentSessionOptions,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
// Merge DB agent config when agentConfigId is provided
|
// Merge DB agent config when agentConfigId is provided (M5-001)
|
||||||
let mergedOptions = options;
|
let mergedOptions = options;
|
||||||
|
let resolvedAgentName: string | undefined;
|
||||||
if (options?.agentConfigId) {
|
if (options?.agentConfigId) {
|
||||||
const agentConfig = await this.brain.agents.findById(options.agentConfigId);
|
const agentConfig = await this.brain.agents.findById(options.agentConfigId);
|
||||||
if (agentConfig) {
|
if (agentConfig) {
|
||||||
|
resolvedAgentName = agentConfig.name;
|
||||||
mergedOptions = {
|
mergedOptions = {
|
||||||
provider: options.provider ?? agentConfig.provider,
|
provider: options.provider ?? agentConfig.provider,
|
||||||
modelId: options.modelId ?? agentConfig.model,
|
modelId: options.modelId ?? agentConfig.model,
|
||||||
@@ -197,6 +205,8 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir: options.sandboxDir,
|
sandboxDir: options.sandboxDir,
|
||||||
isAdmin: options.isAdmin,
|
isAdmin: options.isAdmin,
|
||||||
agentConfigId: options.agentConfigId,
|
agentConfigId: options.agentConfigId,
|
||||||
|
userId: options.userId,
|
||||||
|
conversationHistory: options.conversationHistory,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`,
|
`Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`,
|
||||||
@@ -330,6 +340,14 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
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);
|
this.sessions.set(sessionId, session);
|
||||||
@@ -458,10 +476,12 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
provider: s.provider,
|
provider: s.provider,
|
||||||
modelId: s.modelId,
|
modelId: s.modelId,
|
||||||
|
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
createdAt: new Date(s.createdAt).toISOString(),
|
||||||
promptCount: s.promptCount,
|
promptCount: s.promptCount,
|
||||||
channels: Array.from(s.channels),
|
channels: Array.from(s.channels),
|
||||||
durationMs: now - s.createdAt,
|
durationMs: now - s.createdAt,
|
||||||
|
metrics: { ...s.metrics },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,13 +492,93 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
provider: s.provider,
|
provider: s.provider,
|
||||||
modelId: s.modelId,
|
modelId: s.modelId,
|
||||||
|
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
createdAt: new Date(s.createdAt).toISOString(),
|
||||||
promptCount: s.promptCount,
|
promptCount: s.promptCount,
|
||||||
channels: Array.from(s.channels),
|
channels: Array.from(s.channels),
|
||||||
durationMs: Date.now() - s.createdAt,
|
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 {
|
addChannel(sessionId: string, channel: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
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 {
|
export interface SessionInfoDto {
|
||||||
id: string;
|
id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
/** M5-005: human-readable agent name when an agent config is applied. */
|
||||||
|
agentName?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
promptCount: number;
|
promptCount: number;
|
||||||
channels: string[];
|
channels: string[];
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
/** M5-007: per-session metrics (token usage, model switches, etc.) */
|
||||||
|
metrics: SessionMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionListDto {
|
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)
|
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
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)
|
// Determine provider/model via routing engine or per-session /model override (M4-012 / M4-007)
|
||||||
let resolvedProvider = data.provider;
|
let resolvedProvider = data.provider;
|
||||||
let resolvedModelId = data.modelId;
|
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,
|
provider: resolvedProvider,
|
||||||
modelId: resolvedModelId,
|
modelId: resolvedModelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
@@ -180,10 +193,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure conversation record exists in the DB before persisting messages
|
// Ensure conversation record exists in the DB before persisting messages
|
||||||
|
// M5-004: Also bind the sessionId to the conversation record
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await this.ensureConversation(conversationId, 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
|
// Persist the user message
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
@@ -234,6 +252,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||||
|
|
||||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
// 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);
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
if (agentSession) {
|
if (agentSession) {
|
||||||
@@ -244,6 +263,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
modelId: agentSession.modelId,
|
modelId: agentSession.modelId,
|
||||||
thinkingLevel: piSession.thinkingLevel,
|
thinkingLevel: piSession.thinkingLevel,
|
||||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||||
|
...(agentSession.agentName ? { agentName: agentSession.agentName } : {}),
|
||||||
...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}),
|
...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -301,6 +321,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
modelId: session.modelId,
|
modelId: session.modelId,
|
||||||
thinkingLevel: session.piSession.thinkingLevel,
|
thinkingLevel: session.piSession.thinkingLevel,
|
||||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
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).
|
* Set a per-conversation model override (M4-007).
|
||||||
* When set, the routing engine is bypassed and the specified model is used.
|
* When set, the routing engine is bypassed and the specified model is used.
|
||||||
* Pass null to clear the override and resume automatic routing.
|
* 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 {
|
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||||
if (modelName) {
|
if (modelName) {
|
||||||
modelOverrides.set(conversationId, modelName);
|
modelOverrides.set(conversationId, modelName);
|
||||||
this.logger.log(`Model override set: conversation=${conversationId} model="${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 {
|
} else {
|
||||||
modelOverrides.delete(conversationId);
|
modelOverrides.delete(conversationId);
|
||||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||||
@@ -341,6 +374,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return modelOverrides.get(conversationId);
|
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.
|
* Ensure a conversation record exists in the DB.
|
||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* 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).
|
* 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,
|
* 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,
|
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
|
// Persist the assistant message with metadata
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(client.id);
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const mockRegistry = {
|
|||||||
|
|
||||||
const mockAgentService = {
|
const mockAgentService = {
|
||||||
getSession: vi.fn(() => undefined),
|
getSession: vi.fn(() => undefined),
|
||||||
|
applyAgentConfig: vi.fn(),
|
||||||
|
updateSessionModel: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSystemOverride = {
|
const mockSystemOverride = {
|
||||||
@@ -38,6 +40,33 @@ const mockRedis = {
|
|||||||
del: vi.fn(),
|
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 {
|
function buildService(): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
@@ -45,6 +74,7 @@ function buildService(): CommandExecutorService {
|
|||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
mockRedis as never,
|
mockRedis as never,
|
||||||
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaic/queue';
|
import type { QueueHandle } from '@mosaic/queue';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||||
|
import type { Brain } from '@mosaic/brain';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.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 { ReloadService } from '../reload/reload.service.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommandExecutorService {
|
export class CommandExecutorService {
|
||||||
@@ -19,6 +21,7 @@ export class CommandExecutorService {
|
|||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
private readonly reloadService: ReloadService | null,
|
private readonly reloadService: ReloadService | null,
|
||||||
@@ -87,7 +90,7 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return await this.handleAgent(args ?? null, conversationId);
|
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||||
case 'provider':
|
case 'provider':
|
||||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
case 'mission':
|
case 'mission':
|
||||||
@@ -239,12 +242,14 @@ export class CommandExecutorService {
|
|||||||
private async handleAgent(
|
private async handleAgent(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args) {
|
if (!args) {
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: true,
|
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,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -258,14 +263,102 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch agent — stub for now (full implementation in P8-015)
|
// 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 {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: true,
|
success: true,
|
||||||
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
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,
|
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(
|
private async handleProvider(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ const mockRedis = {
|
|||||||
keys: vi.fn().mockResolvedValue([]),
|
keys: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockBrain = {
|
||||||
|
agents: {
|
||||||
|
findByName: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findById: vi.fn().mockResolvedValue(undefined),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -54,6 +62,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
mockRedis as never,
|
mockRedis as never,
|
||||||
|
mockBrain as never,
|
||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (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,
|
"when": 1774225763410,
|
||||||
"tag": "0005_minor_champions",
|
"tag": "0005_minor_champions",
|
||||||
"breakpoints": true
|
"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' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||||
agentId: uuid('agent_id').references(() => agents.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),
|
archived: boolean('archived').notNull().default(false),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
Reference in New Issue
Block a user