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

@@ -37,6 +37,7 @@ export interface ParsedPhaseFile {
title: string;
dependencies: string[];
body: string;
action?: 'create' | 'update' | 'delete';
}
export interface ParsedTaskFile {
@@ -46,6 +47,9 @@ export interface ParsedTaskFile {
type: string;
dependencies: string[];
body: string;
action?: 'create' | 'update' | 'delete';
phaseId?: string;
parentTaskId?: string;
}
export interface ParsedDecisionFile {
@@ -61,6 +65,7 @@ export interface ParsedPageFile {
title: string;
summary: string;
body: string;
action?: 'create' | 'update' | 'delete';
}
// =============================================================================
@@ -324,11 +329,13 @@ export function readPhaseFiles(agentWorkdir: string): ParsedPhaseFile[] {
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
id,
title: String(data.title ?? ''),
dependencies: deps,
body,
action,
};
});
}
@@ -338,6 +345,7 @@ export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
return readFrontmatterDir(dirPath, (data, body, filename) => {
const id = filename.replace(/\.md$/, '');
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
id,
title: String(data.title ?? ''),
@@ -345,6 +353,9 @@ export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
type: String(data.type ?? 'auto'),
dependencies: deps,
body,
action,
phaseId: data.phaseId ? String(data.phaseId) : undefined,
parentTaskId: data.parentTaskId ? String(data.parentTaskId) : undefined,
};
});
}
@@ -367,11 +378,13 @@ export function readPageFiles(agentWorkdir: string): ParsedPageFile[] {
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
return readFrontmatterDir(dirPath, (data, body, filename) => {
const pageId = filename.replace(/\.md$/, '');
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
return {
pageId,
title: String(data.title ?? ''),
summary: String(data.summary ?? ''),
body,
action,
};
});
}

View File

@@ -26,6 +26,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import { generateUniqueAlias } from './alias.js';
import type {
EventBus,
@@ -81,11 +82,12 @@ export class MultiProviderAgentManager implements AgentManager {
private logChunkRepository?: LogChunkRepository,
private debug: boolean = false,
processManagerOverride?: ProcessManager,
private chatSessionRepository?: ChatSessionRepository,
) {
this.signalManager = new FileSystemSignalManager();
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager);
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository);
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
this.lifecycleController = createLifecycleController({
repository,

View File

@@ -14,6 +14,7 @@ import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/reposi
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type {
EventBus,
AgentStoppedEvent,
@@ -90,6 +91,7 @@ export class OutputHandler {
private taskRepository?: TaskRepository,
private pageRepository?: PageRepository,
private signalManager?: SignalManager,
private chatSessionRepository?: ChatSessionRepository,
) {}
/**
@@ -390,6 +392,10 @@ export class OutputHandler {
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
break;
case 'questions':
// Chat mode: process output files before handling questions
if (mode === 'chat') {
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
}
await this.handleQuestions(agentId, agent, signal.questions, sessionId);
break;
case 'error':
@@ -655,6 +661,194 @@ export class OutputHandler {
}
break;
}
case 'chat': {
const chatPhases = readPhaseFiles(agentWorkdir);
const chatTasks = readTaskFiles(agentWorkdir);
const chatPages = readPageFiles(agentWorkdir);
if (canWriteChangeSets) {
const entries: CreateChangeSetEntryData[] = [];
let sortOrd = 0;
// Process phases
if (this.phaseRepository) {
for (const p of chatPhases) {
try {
const action = p.action ?? 'create';
if (action === 'create') {
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
const created = await this.phaseRepository.create({
id: p.id ?? undefined,
initiativeId: initiativeId!,
name: p.title,
content: tiptapContent,
});
entries.push({ entityType: 'phase', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
} else if (action === 'update') {
const existing = await this.phaseRepository.findById(p.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
await this.phaseRepository.update(p.id, { name: p.title, content: tiptapContent });
const updated = await this.phaseRepository.findById(p.id);
entries.push({ entityType: 'phase', entityId: p.id, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
} else if (action === 'delete') {
const existing = await this.phaseRepository.findById(p.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.phaseRepository.delete(p.id);
entries.push({ entityType: 'phase', entityId: p.id, action: 'delete', previousState, sortOrder: sortOrd++ });
}
} catch (err) {
log.warn({ agentId, phase: p.title, action: p.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat phase');
}
}
}
// Process tasks — two pass (create first, then deps)
if (this.taskRepository) {
const fileIdToDbId = new Map<string, string>();
for (const t of chatTasks) {
try {
const action = t.action ?? 'create';
if (action === 'create') {
const created = await this.taskRepository.create({
initiativeId: initiativeId!,
phaseId: t.phaseId ?? null,
parentTaskId: t.parentTaskId ?? null,
name: t.title,
description: t.body ?? undefined,
category: (t.category as any) ?? 'execute',
type: (t.type as any) ?? 'auto',
});
fileIdToDbId.set(t.id, created.id);
entries.push({ entityType: 'task', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
} else if (action === 'update') {
const existing = await this.taskRepository.findById(t.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.taskRepository.update(t.id, {
name: t.title,
description: t.body ?? undefined,
category: (t.category as any) ?? existing.category,
type: (t.type as any) ?? existing.type,
});
const updated = await this.taskRepository.findById(t.id);
fileIdToDbId.set(t.id, t.id);
entries.push({ entityType: 'task', entityId: t.id, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
} else if (action === 'delete') {
const existing = await this.taskRepository.findById(t.id);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.taskRepository.delete(t.id);
entries.push({ entityType: 'task', entityId: t.id, action: 'delete', previousState, sortOrder: sortOrd++ });
}
} catch (err) {
log.warn({ agentId, task: t.title, action: t.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat task');
}
}
// Second pass: deps for created tasks
for (const t of chatTasks) {
if (t.action !== 'create' || t.dependencies.length === 0) continue;
const taskDbId = fileIdToDbId.get(t.id);
if (!taskDbId) continue;
for (const depFileId of t.dependencies) {
const depDbId = fileIdToDbId.get(depFileId) ?? depFileId;
try {
await this.taskRepository.createDependency(taskDbId, depDbId);
entries.push({ entityType: 'task_dependency', entityId: `${taskDbId}:${depDbId}`, action: 'create', newState: JSON.stringify({ taskId: taskDbId, dependsOnTaskId: depDbId }), sortOrder: sortOrd++ });
} catch (err) {
log.warn({ agentId, taskDbId, depFileId, err: err instanceof Error ? err.message : String(err) }, 'failed to create chat task dependency');
}
}
}
}
// Process pages
if (this.pageRepository) {
for (const p of chatPages) {
try {
const action = p.action ?? 'create';
if (action === 'create') {
const tiptapJson = markdownToTiptapJson(p.body || '');
const created = await this.pageRepository.create({
id: p.pageId ?? undefined,
initiativeId: initiativeId!,
title: p.title,
content: JSON.stringify(tiptapJson),
});
entries.push({ entityType: 'page', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
} else if (action === 'update') {
const existing = await this.pageRepository.findById(p.pageId);
if (!existing) continue;
const previousState = JSON.stringify(existing);
const tiptapJson = markdownToTiptapJson(p.body || '');
await this.pageRepository.update(p.pageId, { content: JSON.stringify(tiptapJson), title: p.title });
const updated = await this.pageRepository.findById(p.pageId);
entries.push({ entityType: 'page', entityId: p.pageId, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
} else if (action === 'delete') {
const existing = await this.pageRepository.findById(p.pageId);
if (!existing) continue;
const previousState = JSON.stringify(existing);
await this.pageRepository.delete(p.pageId);
entries.push({ entityType: 'page', entityId: p.pageId, action: 'delete', previousState, sortOrder: sortOrd++ });
}
} catch (err) {
log.warn({ agentId, pageId: p.pageId, action: p.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat page');
}
}
}
// Create change set
let changeSetId: string | null = null;
if (entries.length > 0) {
try {
const cs = await this.changeSetRepository!.createWithEntries({
agentId,
agentName: agent.name,
initiativeId: initiativeId!,
mode: 'chat' as 'plan' | 'detail' | 'refine',
summary: summary?.body ?? `Chat: ${entries.length} changes applied`,
}, entries);
changeSetId = cs.id;
this.eventBus?.emit({
type: 'changeset:created' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'chat', entryCount: entries.length },
});
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record chat change set');
}
}
// Store assistant message in chat session
if (this.chatSessionRepository) {
try {
const session = await this.chatSessionRepository.findActiveSessionByAgentId(agentId);
if (session) {
const assistantContent = summary?.body ?? `Applied ${entries.length} changes`;
await this.chatSessionRepository.createMessage({
chatSessionId: session.id,
role: 'assistant',
content: assistantContent,
changeSetId,
});
this.eventBus?.emit({
type: 'chat:message_created' as const,
timestamp: new Date(),
payload: { chatSessionId: session.id, role: 'assistant' },
});
}
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to store chat assistant message');
}
}
resultMessage = summary?.body ?? `${entries.length} changes applied`;
} else {
resultMessage = JSON.stringify({ summary: summary?.body, phases: chatPhases, tasks: chatTasks, pages: chatPages });
}
break;
}
}
const resultPayload: AgentResult = {
@@ -738,6 +932,7 @@ export class OutputHandler {
case 'plan': return 'plan_complete';
case 'detail': return 'detail_complete';
case 'refine': return 'refine_complete';
case 'chat': return 'chat_complete';
default: return 'task_complete';
}
}

View File

@@ -0,0 +1,71 @@
/**
* Chat mode prompt — iterative conversation for refining phases/tasks/pages.
*/
import { INPUT_FILES, SIGNAL_FORMAT, ID_GENERATION } from './shared.js';
export interface ChatHistoryEntry {
role: 'user' | 'assistant' | 'system';
content: string;
}
export function buildChatPrompt(
targetType: 'phase' | 'task',
chatHistory: ChatHistoryEntry[],
userInstruction: string,
): string {
const historyBlock = chatHistory.length > 0
? `<chat_history>\n${chatHistory.map(m => `[${m.role}]: ${m.content}`).join('\n\n')}\n</chat_history>`
: '';
return `<role>
You are an Architect agent in chat mode. You iteratively refine ${targetType} structure and content through conversation with the user. You do NOT write code.
</role>
${INPUT_FILES}
${ID_GENERATION}
${SIGNAL_FORMAT}
${historyBlock}
<current_instruction>
${userInstruction}
</current_instruction>
<output_format>
Write output files to \`.cw/output/\` with YAML frontmatter including an \`action\` field:
**Phases** — \`.cw/output/phases/{id}.md\`:
- \`action: create\` — new phase. Filename = new ID from \`cw id\`.
- \`action: update\` — modify existing. Filename = existing phase ID.
- \`action: delete\` — remove. Filename = existing phase ID. Body can be empty.
- Frontmatter: \`title\`, \`action\`, \`dependencies\` (array of phase IDs)
- Body: Phase description in markdown
**Tasks** — \`.cw/output/tasks/{id}.md\`:
- \`action: create\` — new task. Filename = new ID from \`cw id\`.
- \`action: update\` — modify existing. Filename = existing task ID.
- \`action: delete\` — remove. Filename = existing task ID. Body can be empty.
- Frontmatter: \`title\`, \`action\`, \`category\`, \`type\`, \`dependencies\` (array of task IDs), \`phaseId\`, \`parentTaskId\`
- Body: Task description in markdown
**Pages** — \`.cw/output/pages/{pageId}.md\`:
- \`action: create\` — new page. Filename = new ID from \`cw id\`.
- \`action: update\` — modify existing. Filename = existing page ID.
- \`action: delete\` — remove. Filename = existing page ID. Body can be empty.
- Frontmatter: \`title\`, \`action\`, \`summary\` (what changed)
- Body: Full replacement markdown content
</output_format>
<summary_file>
After writing output files, write \`.cw/output/SUMMARY.md\` with a brief description of what you changed and why.
</summary_file>
<rules>
- After applying changes, ALWAYS signal "questions" with: \`{ "status": "questions", "questions": [{ "id": "next", "question": "What would you like to do next?" }] }\`
- Only signal "done" when the user explicitly says they are finished
- Only signal "error" for unrecoverable problems
- Apply the minimal set of changes needed for the user's instruction
- Preserve existing entity IDs — only use \`cw id\` for new entities
- When updating, only include changed fields in frontmatter (plus required \`action\` and \`title\`)
</rules>`;
}

View File

@@ -11,4 +11,6 @@ export { buildDiscussPrompt } from './discuss.js';
export { buildPlanPrompt } from './plan.js';
export { buildDetailPrompt } from './detail.js';
export { buildRefinePrompt } from './refine.js';
export { buildChatPrompt } from './chat.js';
export type { ChatHistoryEntry } from './chat.js';
export { buildWorkspaceLayout } from './workspace.js';

View File

@@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
* - plan: Plan initiative into phases
* - detail: Detail phase into individual tasks
*/
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine';
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat';
/**
* Context data written as input files in agent workdir before spawn.