feat: Add persistent chat sessions for iterative phase/task refinement
Introduces a chat loop where users send instructions to an agent that applies changes (create/update/delete phases, tasks, pages) and stays alive for follow-up messages. Includes schema + migration, repository layer, chat prompt, file-io action field extension, output handler chat mode, revert support for deletes, tRPC procedures, events, frontend slide-over UI with inline changeset display and revert, and docs.
This commit is contained in:
187
apps/server/trpc/routers/chat-session.ts
Normal file
187
apps/server/trpc/routers/chat-session.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Chat Session Router — send messages, get session, close session
|
||||
*/
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import {
|
||||
requireAgentManager,
|
||||
requireInitiativeRepository,
|
||||
requirePhaseRepository,
|
||||
requirePageRepository,
|
||||
requireTaskRepository,
|
||||
requireChatSessionRepository,
|
||||
} from './_helpers.js';
|
||||
import { gatherInitiativeContext } from './architect.js';
|
||||
import { buildChatPrompt, type ChatHistoryEntry } from '../../agent/prompts/index.js';
|
||||
|
||||
const MAX_HISTORY_MESSAGES = 30;
|
||||
|
||||
export function chatSessionProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
sendChatMessage: publicProcedure
|
||||
.input(z.object({
|
||||
targetType: z.enum(['phase', 'task']),
|
||||
targetId: z.string().min(1),
|
||||
initiativeId: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
provider: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
const chatRepo = requireChatSessionRepository(ctx);
|
||||
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||
const taskRepo = requireTaskRepository(ctx);
|
||||
|
||||
const initiative = await initiativeRepo.findById(input.initiativeId);
|
||||
if (!initiative) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
|
||||
}
|
||||
|
||||
// Find or create active session
|
||||
let session = await chatRepo.findActiveSession(input.targetType, input.targetId);
|
||||
if (!session) {
|
||||
session = await chatRepo.createSession({
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
initiativeId: input.initiativeId,
|
||||
});
|
||||
}
|
||||
|
||||
// Store user message
|
||||
await chatRepo.createMessage({
|
||||
chatSessionId: session.id,
|
||||
role: 'user',
|
||||
content: input.message,
|
||||
});
|
||||
|
||||
ctx.eventBus.emit({
|
||||
type: 'chat:message_created' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { chatSessionId: session.id, role: 'user' as const },
|
||||
});
|
||||
|
||||
// Check if agent exists and is waiting for input
|
||||
if (session.agentId) {
|
||||
const agent = await agentManager.get(session.agentId);
|
||||
if (agent && agent.status === 'waiting_for_input') {
|
||||
// Resume the existing agent
|
||||
await agentManager.resume(agent.id, { 'chat-response': input.message });
|
||||
return { sessionId: session.id, agentId: agent.id, action: 'resumed' as const };
|
||||
}
|
||||
|
||||
// Agent exists but not waiting — dismiss it
|
||||
if (agent && !agent.userDismissedAt) {
|
||||
if (['running'].includes(agent.status)) {
|
||||
await agentManager.stop(agent.id);
|
||||
}
|
||||
await agentManager.dismiss(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn fresh agent with chat history + context
|
||||
const messages = await chatRepo.findMessagesBySessionId(session.id);
|
||||
const chatHistory: ChatHistoryEntry[] = messages
|
||||
.slice(-MAX_HISTORY_MESSAGES)
|
||||
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
|
||||
|
||||
const context = await gatherInitiativeContext(
|
||||
ctx.phaseRepository,
|
||||
ctx.taskRepository,
|
||||
ctx.pageRepository,
|
||||
input.initiativeId,
|
||||
);
|
||||
|
||||
const prompt = buildChatPrompt(input.targetType, chatHistory, input.message);
|
||||
|
||||
// Create a task for the chat agent
|
||||
const targetName = input.targetType === 'phase'
|
||||
? (await ctx.phaseRepository?.findById(input.targetId))?.name ?? input.targetId
|
||||
: (await ctx.taskRepository?.findById(input.targetId))?.name ?? input.targetId;
|
||||
|
||||
const task = await taskRepo.create({
|
||||
initiativeId: input.initiativeId,
|
||||
name: `Chat: ${targetName}`,
|
||||
description: `Iterative chat refinement of ${input.targetType}`,
|
||||
category: 'discuss',
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
// Determine target phase/task for input context
|
||||
const targetPhase = input.targetType === 'phase'
|
||||
? await ctx.phaseRepository?.findById(input.targetId)
|
||||
: undefined;
|
||||
const targetTask = input.targetType === 'task'
|
||||
? await ctx.taskRepository?.findById(input.targetId)
|
||||
: undefined;
|
||||
|
||||
const agent = await agentManager.spawn({
|
||||
taskId: task.id,
|
||||
prompt,
|
||||
mode: 'chat',
|
||||
provider: input.provider,
|
||||
initiativeId: input.initiativeId,
|
||||
inputContext: {
|
||||
initiative,
|
||||
phase: targetPhase ?? undefined,
|
||||
task: targetTask ?? undefined,
|
||||
pages: context.pages.length > 0 ? context.pages : undefined,
|
||||
phases: context.phases.length > 0 ? context.phases : undefined,
|
||||
tasks: context.tasks.length > 0 ? context.tasks : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Link agent to session
|
||||
await chatRepo.updateSession(session.id, { agentId: agent.id });
|
||||
|
||||
return { sessionId: session.id, agentId: agent.id, action: 'spawned' as const };
|
||||
}),
|
||||
|
||||
getChatSession: publicProcedure
|
||||
.input(z.object({
|
||||
targetType: z.enum(['phase', 'task']),
|
||||
targetId: z.string().min(1),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const chatRepo = requireChatSessionRepository(ctx);
|
||||
const session = await chatRepo.findActiveSession(input.targetType, input.targetId);
|
||||
if (!session) return null;
|
||||
const messages = await chatRepo.findMessagesBySessionId(session.id);
|
||||
return { ...session, messages };
|
||||
}),
|
||||
|
||||
closeChatSession: publicProcedure
|
||||
.input(z.object({ sessionId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const chatRepo = requireChatSessionRepository(ctx);
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
|
||||
const session = await chatRepo.findSessionById(input.sessionId);
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Chat session '${input.sessionId}' not found` });
|
||||
}
|
||||
|
||||
// Stop and dismiss agent if active
|
||||
if (session.agentId) {
|
||||
const agent = await agentManager.get(session.agentId);
|
||||
if (agent && !agent.userDismissedAt) {
|
||||
if (['running', 'waiting_for_input'].includes(agent.status)) {
|
||||
await agentManager.stop(agent.id);
|
||||
}
|
||||
await agentManager.dismiss(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
await chatRepo.updateSession(input.sessionId, { status: 'closed' });
|
||||
|
||||
ctx.eventBus.emit({
|
||||
type: 'chat:session_closed' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { chatSessionId: session.id, initiativeId: session.initiativeId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user