Compare commits

..

3 Commits

Author SHA1 Message Date
21c045559d fix(gateway): remove duplicate mockBrain declaration and fix prettier formatting after rebase
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Rebase conflict resolution introduced a duplicate `mockBrain` variable in
commands.integration.spec.ts. Also fixes prettier formatting on
command-executor-p8012.spec.ts that was introduced during conflict resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:08:22 -05:00
719f661963 feat(M4-013,M5-001,M5-002,M5-003): routing e2e tests, agent config loading, model+agent switching
- M4-013: Add routing-e2e.test.ts with 9 integration tests covering the full
  classify → match rules → routing decision pipeline; includes coding→Opus,
  summarization→GLM-5, conversation→Sonnet, cheap-tier→Haiku, /model bypass,
  unhealthy-provider fallback, and research→Codex scenarios

- M5-001: Store resolvedAgentName during session creation when agentConfigId
  is provided; expose agentName on AgentSession and SessionInfoDto; emit
  agentName in session:info from chat.gateway.ts (message handler and
  set:thinking handler); preserve userId and conversationHistory in merged
  options so they are not lost when agent config is applied

- M5-002: Add AgentService.updateSessionModel() to update live session
  modelId metadata; wire it into ChatGateway.setModelOverride() so the
  /model command immediately reflects in session:info; add
  ChatGateway.broadcastSessionInfo() to push updated session:info to all
  clients watching a conversation on model or agent switch

- M5-003: Implement /agent <name> command end-to-end: inject Brain into
  CommandExecutorService; replace stub handleAgent() with real
  brain.agents.findByName() + findById() lookup; call
  agentService.applyAgentConfig() to update live session; emit session:info
  via chatGateway.broadcastSessionInfo(); update tests to mock brain and
  agentService.applyAgentConfig; add AgentService.applyAgentConfig() method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:07:07 -05:00
1035d13fc0 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
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-23 00:58:07 +00:00
10 changed files with 3026 additions and 57 deletions

View File

@@ -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';
@@ -97,6 +97,8 @@ export interface AgentSession {
agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */
agentName?: string;
/** M5-007: per-session metrics. */
metrics: SessionMetrics;
}
@Injectable()
@@ -340,6 +342,12 @@ export class AgentService implements OnModuleDestroy {
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);
@@ -467,24 +475,95 @@ export class AgentService implements OnModuleDestroy {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
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 },
}));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
return {
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).
* Records the model change in session metadata so subsequent session:info
* emissions reflect the new model. The Pi session itself is not reconstructed —
* the new model takes effect on the next message prompt.
* 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 a new session is created for
* System prompt and tools take effect when the next session is created for
* this conversationId (they are baked in at session creation time).
*/
applyAgentConfig(
@@ -505,35 +584,6 @@ export class AgentService implements OnModuleDestroy {
);
}
listSessions(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
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,
}));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
return {
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,
};
}
addChannel(sessionId: string, channel: string): void {
const session = this.sessions.get(sessionId);
if (session) {

View File

@@ -1,13 +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;
/** Human-readable agent name when an agent config is applied (M5-001). */
/** 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 {

View File

@@ -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) {
@@ -325,17 +344,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* Set a per-conversation model override (M4-007 / M5-002).
* When set, the routing engine is bypassed and the specified model is used.
* Pass null to clear the override and resume automatic routing.
* M5-002: Also updates the live session's modelId and emits session:info.
* 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 live session model so next session:info reflects the new model
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
this.agentService.updateSessionModel(conversationId, modelName);
// Broadcast updated session:info to all clients watching this conversation
// M5-005: Broadcast session:info to all clients subscribed to this conversation
this.broadcastSessionInfo(conversationId);
} else {
modelOverrides.delete(conversationId);
@@ -351,10 +371,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
/**
* Broadcast session:info to all clients currently subscribed to a conversation (M5-002/M5-003).
* 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 }): void {
broadcastSessionInfo(
conversationId: string,
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
): void {
const agentSession = this.agentService.getSession(conversationId);
if (!agentSession) return;
@@ -367,6 +390,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
thinkingLevel: piSession.thinkingLevel,
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
...(resolvedAgentName ? { agentName: resolvedAgentName } : {}),
...(extra?.routingDecision ? { routingDecision: extra.routingDecision } : {}),
};
// Emit to all clients currently subscribed to this conversation
@@ -402,6 +426,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,
@@ -478,6 +541,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;

View File

@@ -42,7 +42,7 @@ const mockRedis = {
// Mock agent config returned by brain.agents.findByName for "my-agent-id"
const mockAgentConfig = {
id: 'agent-uuid-123',
id: 'my-agent-id',
name: 'my-agent-id',
model: 'claude-sonnet-4-6',
provider: 'anthropic',
@@ -57,11 +57,12 @@ const mockAgentConfig = {
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 === 'agent-uuid-123' ? mockAgentConfig : undefined),
Promise.resolve(id === 'my-agent-id' ? mockAgentConfig : undefined),
),
create: vi.fn(),
},

View File

@@ -242,13 +242,14 @@ export class CommandExecutorService {
private async handleAgent(
args: string | null,
conversationId: string,
_userId: string,
userId: string,
): Promise<SlashCommandResultPayload> {
if (!args) {
return {
command: 'agent',
success: true,
message: 'Usage: /agent <agent-name> 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,
};
}
@@ -262,12 +263,58 @@ export class CommandExecutorService {
};
}
// 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 lookup
// 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);
}
@@ -280,7 +327,7 @@ export class CommandExecutorService {
};
}
// Apply agent config to the live session metadata (M5-003)
// Apply the agent config to the live session and emit session:info (M5-003)
this.agentService.applyAgentConfig(
conversationId,
agentConfig.id,
@@ -288,7 +335,7 @@ export class CommandExecutorService {
agentConfig.model ?? undefined,
);
// Broadcast updated session:info so TUI reflects new agent/model (M5-003)
// Broadcast updated session:info so TUI TopBar reflects new agent/model
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
this.logger.log(
@@ -298,7 +345,7 @@ export class CommandExecutorService {
return {
command: 'agent',
success: true,
message: `Switched to agent "${agentConfig.name}". Model: ${agentConfig.model ?? 'default'}.`,
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 },
};

View File

@@ -39,14 +39,6 @@ const mockRedis = {
keys: vi.fn().mockResolvedValue([]),
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService {
const svc = new CommandRegistryService();
svc.onModuleInit(); // seed core commands
return svc;
}
const mockBrain = {
agents: {
findByName: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +47,14 @@ const mockBrain = {
},
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService {
const svc = new CommandRegistryService();
svc.onModuleInit(); // seed core commands
return svc;
}
function buildExecutor(registry: CommandRegistryService): CommandExecutorService {
return new CommandExecutorService(
registry as never,

View File

@@ -0,0 +1 @@
ALTER TABLE "conversations" ADD COLUMN "session_id" text;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1774225763410,
"tag": "0005_minor_champions",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1774227064500,
"tag": "0006_swift_shen",
"breakpoints": true
}
]
}

View File

@@ -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(),