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:
Lukas May
2026-03-04 10:14:28 +01:00
parent d6fb1abcba
commit fcf822363c
40 changed files with 1414 additions and 27 deletions

View File

@@ -17,6 +17,7 @@ import type { AccountRepository } from '../../db/repositories/account-repository
import type { ChangeSetRepository } from '../../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
@@ -192,3 +193,13 @@ export function requireConversationRepository(ctx: TRPCContext): ConversationRep
}
return ctx.conversationRepository;
}
export function requireChatSessionRepository(ctx: TRPCContext): ChatSessionRepository {
if (!ctx.chatSessionRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Chat session repository not available',
});
}
return ctx.chatSessionRepository;
}

View File

@@ -25,7 +25,7 @@ import type { PageRepository } from '../../db/repositories/page-repository.js';
import type { Phase, Task } from '../../db/schema.js';
import type { PageForSerialization } from '../../agent/content-serializer.js';
async function gatherInitiativeContext(
export async function gatherInitiativeContext(
phaseRepo: PhaseRepository | undefined,
taskRepo: TaskRepository | undefined,
pageRepo: PageRepository | undefined,

View File

@@ -114,6 +114,20 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
} else if (entry.action === 'update' && entry.previousState) {
const prev = JSON.parse(entry.previousState);
switch (entry.entityType) {
case 'phase':
await phaseRepo.update(entry.entityId, {
name: prev.name,
content: prev.content,
});
break;
case 'task':
await taskRepo.update(entry.entityId, {
name: prev.name,
description: prev.description,
category: prev.category,
type: prev.type,
});
break;
case 'page':
await pageRepo.update(entry.entityId, {
content: prev.content,
@@ -126,6 +140,19 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
});
break;
}
} else if (entry.action === 'delete' && entry.previousState) {
const prev = JSON.parse(entry.previousState);
switch (entry.entityType) {
case 'phase':
try { await phaseRepo.create({ id: prev.id, initiativeId: prev.initiativeId, name: prev.name, content: prev.content }); } catch { /* already exists */ }
break;
case 'task':
try { await taskRepo.create({ id: prev.id, initiativeId: prev.initiativeId, phaseId: prev.phaseId, parentTaskId: prev.parentTaskId, name: prev.name, description: prev.description, category: prev.category, type: prev.type }); } catch { /* already exists */ }
break;
case 'page':
try { await pageRepo.create({ id: prev.id, initiativeId: prev.initiativeId, parentPageId: prev.parentPageId, title: prev.title, content: prev.content }); } catch { /* already exists */ }
break;
}
}
} catch (err) {
// Log but continue reverting other entries

View 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 };
}),
};
}